elsecase
componentdata-explorersource: ok · refreshed
ResponsiveDataExplorer
A responsive data-management workflow with search, filters, sorting, pagination, selection, URL state, desktop tables, and mobile cards.
npx shadcn add "https://elsecase.vercel.app/r/data-explorer.json"free
Where it goes
statsdata-table
Interaction
animated-counterdropdown
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@tanstack/react-table@^9.1.2
Registry dependencies
https://elsecase.vercel.app/r/async-state.json
Files1 · source cached 10/7/2026, 4:49:16 PM
registry/data-explorer/data-explorer.tsx
"use client"
import {
createColumnHelper,
createPaginatedRowModel,
createSortedRowModel,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_alphanumericCaseSensitive,
sortFn_basic,
sortFn_datetime,
sortFn_text,
sortFn_textCaseSensitive,
tableFeatures,
useTable,
type CellData,
type ColumnDef,
type RowData,
type RowSelectionState,
type SortingState,
type Updater,
} from "@tanstack/react-table"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type InputHTMLAttributes,
type ReactNode,
} from "react"
import { AsyncState, type AsyncStatus } from "@/components/async-state"
export interface DataExplorerPagination {
pageIndex: number
pageSize: number
}
export interface DataExplorerSearchConfig<TData> {
getSearchText: (row: TData) => string
label?: string
placeholder?: string
}
export interface DataExplorerFilterOption {
label: string
value: string
}
export interface DataExplorerFilterConfig<TData> {
id: string
label: string
getValue: (row: TData) => string
options: DataExplorerFilterOption[]
}
export interface DataExplorerUrlState {
search: string
filters: Record<string, string>
pagination: DataExplorerPagination
sorting: SortingState
}
const dataExplorerFeatures = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: {
alphanumeric: sortFn_alphanumeric,
alphanumericCaseSensitive: sortFn_alphanumericCaseSensitive,
basic: sortFn_basic,
datetime: sortFn_datetime,
text: sortFn_text,
textCaseSensitive: sortFn_textCaseSensitive,
},
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
rowSelectionFeature,
})
export type DataExplorerColumnDef<TData extends RowData> = ColumnDef<
typeof dataExplorerFeatures,
TData,
CellData
>
export function createDataExplorerColumnHelper<TData extends RowData>() {
return createColumnHelper<typeof dataExplorerFeatures, TData>()
}
export interface DataExplorerProps<TData extends RowData> {
data: TData[]
columns: DataExplorerColumnDef<TData>[]
getRowId: (row: TData) => string
status?: AsyncStatus
error?: unknown
search?: DataExplorerSearchConfig<TData>
filters?: DataExplorerFilterConfig<TData>[]
pagination?: DataExplorerPagination
sorting?: SortingState
selection?: RowSelectionState
mobileCard: (row: TData) => ReactNode
bulkActions?: ReactNode
emptyState?: ReactNode
noResultsState?: ReactNode
onRetry?: () => void | Promise<void>
onPaginationChange?: (pagination: DataExplorerPagination) => void
onSortingChange?: (sorting: SortingState) => void
onSelectionChange?: (selection: RowSelectionState) => void
syncStateToUrl?: boolean
}
const defaultPagination: DataExplorerPagination = {
pageIndex: 0,
pageSize: 10,
}
const emptyFilters: [] = []
const searchParameter = "q"
const sortParameter = "sort"
const pageParameter = "page"
const pageSizeParameter = "size"
const filterPrefix = "filter."
function resolveUpdater<T>(updater: Updater<T>, previous: T): T {
return typeof updater === "function"
? (updater as (value: T) => T)(previous)
: updater
}
function validPositiveInteger(value: string | null, fallback: number) {
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
}
function parseSorting(value: string | null): SortingState {
if (!value) return []
return value
.split(",")
.map((entry) => {
const separator = entry.lastIndexOf(".")
if (separator < 1) return null
let id: string
try {
id = decodeURIComponent(entry.slice(0, separator))
} catch {
return null
}
const direction = entry.slice(separator + 1)
if (!id || (direction !== "asc" && direction !== "desc")) return null
return { id, desc: direction === "desc" }
})
.filter((entry): entry is SortingState[number] => entry !== null)
}
function serializeSorting(sorting: SortingState) {
return sorting
.map(({ id, desc }) => `${encodeURIComponent(id)}.${desc ? "desc" : "asc"}`)
.join(",")
}
export function parseDataExplorerUrlState(
parameters: URLSearchParams,
filterIds: string[],
fallbackPagination: DataExplorerPagination = defaultPagination,
): DataExplorerUrlState {
const filters = Object.fromEntries(
filterIds.map((id) => [id, parameters.get(`${filterPrefix}${id}`) ?? ""]),
)
return {
search: parameters.get(searchParameter) ?? "",
filters,
sorting: parseSorting(parameters.get(sortParameter)),
pagination: {
pageIndex: validPositiveInteger(parameters.get(pageParameter), 1) - 1,
pageSize: validPositiveInteger(
parameters.get(pageSizeParameter),
fallbackPagination.pageSize,
),
},
}
}
export function serializeDataExplorerUrlState(
current: URLSearchParams,
state: DataExplorerUrlState,
filterIds: string[],
fallbackPagination: DataExplorerPagination = defaultPagination,
) {
const parameters = new URLSearchParams(current.toString())
const setOrDelete = (key: string, value: string, defaultValue = "") => {
if (value === defaultValue) parameters.delete(key)
else parameters.set(key, value)
}
setOrDelete(searchParameter, state.search.trim())
setOrDelete(sortParameter, serializeSorting(state.sorting))
setOrDelete(pageParameter, String(state.pagination.pageIndex + 1), "1")
setOrDelete(
pageSizeParameter,
String(state.pagination.pageSize),
String(fallbackPagination.pageSize),
)
filterIds.forEach((id) =>
setOrDelete(`${filterPrefix}${id}`, state.filters[id] ?? ""),
)
return parameters
}
export function filterDataExplorerRows<TData>(
data: TData[],
search: string,
searchConfig: DataExplorerSearchConfig<TData> | undefined,
filters: DataExplorerFilterConfig<TData>[],
filterValues: Record<string, string>,
) {
const normalizedSearch = search.trim().toLocaleLowerCase()
return data.filter((row) => {
const matchesSearch =
!normalizedSearch ||
!searchConfig ||
searchConfig
.getSearchText(row)
.toLocaleLowerCase()
.includes(normalizedSearch)
const matchesFilters = filters.every((filter) => {
const expected = filterValues[filter.id]
return !expected || filter.getValue(row) === expected
})
return matchesSearch && matchesFilters
})
}
function IndeterminateCheckbox({
className,
indeterminate,
...props
}: InputHTMLAttributes<HTMLInputElement> & { indeterminate?: boolean }) {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) ref.current.indeterminate = Boolean(indeterminate)
}, [indeterminate])
return (
<input
{...props}
className={joinClassNames(
"border-input accent-primary focus-visible:ring-ring size-5 shrink-0 rounded border focus-visible:ring-2 focus-visible:ring-offset-2",
className,
)}
ref={ref}
type="checkbox"
/>
)
}
function DefaultEmptyState() {
return (
<div className="bg-card flex min-h-52 flex-col items-center justify-center gap-2 rounded-lg border p-8 text-center">
<p className="font-semibold">No records yet</p>
<p className="text-muted-foreground max-w-sm text-sm">
Data will appear here when records are available.
</p>
</div>
)
}
function joinClassNames(...values: Array<string | false | undefined>) {
return values.filter(Boolean).join(" ")
}
export function ResponsiveDataExplorer<TData extends RowData>({
data,
columns,
getRowId,
status,
error,
search: searchConfig,
filters,
pagination,
sorting,
selection,
mobileCard,
bulkActions,
emptyState,
noResultsState,
onRetry,
onPaginationChange,
onSortingChange,
onSelectionChange,
syncStateToUrl = false,
}: DataExplorerProps<TData>) {
const activeFilters = filters ?? emptyFilters
const initialPagination = pagination ?? defaultPagination
const [searchValue, setSearchValue] = useState("")
const [filterValues, setFilterValues] = useState<Record<string, string>>({})
const [internalPagination, setInternalPagination] =
useState<DataExplorerPagination>(initialPagination)
const [internalSorting, setInternalSorting] = useState<SortingState>(
sorting ?? [],
)
const [internalSelection, setInternalSelection] = useState<RowSelectionState>(
selection ?? {},
)
const [urlReady, setUrlReady] = useState(!syncStateToUrl)
const activePagination = pagination ?? internalPagination
const activeSorting = sorting ?? internalSorting
const activeSelection = selection ?? internalSelection
const filterIds = useMemo(
() => activeFilters.map(({ id }) => id),
[activeFilters],
)
const filterIdsKey = filterIds.join("\u001f")
const didRestoreUrl = useRef(false)
const changePagination = useCallback(
(updater: Updater<DataExplorerPagination>) => {
const next = resolveUpdater(updater, activePagination)
if (pagination === undefined) setInternalPagination(next)
onPaginationChange?.(next)
},
[activePagination, onPaginationChange, pagination],
)
const changeSorting = useCallback(
(updater: Updater<SortingState>) => {
const next = resolveUpdater(updater, activeSorting)
if (sorting === undefined) setInternalSorting(next)
onSortingChange?.(next)
},
[activeSorting, onSortingChange, sorting],
)
const changeSelection = useCallback(
(updater: Updater<RowSelectionState>) => {
const next = resolveUpdater(updater, activeSelection)
if (selection === undefined) setInternalSelection(next)
onSelectionChange?.(next)
},
[activeSelection, onSelectionChange, selection],
)
useEffect(() => {
if (!syncStateToUrl) return
const restoreUrlState = () => {
const restored = parseDataExplorerUrlState(
new URLSearchParams(window.location.search),
filterIdsKey ? filterIdsKey.split("\u001f") : [],
initialPagination,
)
setSearchValue(restored.search)
setFilterValues(restored.filters)
if (pagination === undefined) setInternalPagination(restored.pagination)
onPaginationChange?.(restored.pagination)
if (sorting === undefined) setInternalSorting(restored.sorting)
onSortingChange?.(restored.sorting)
setUrlReady(true)
}
if (!didRestoreUrl.current) {
didRestoreUrl.current = true
restoreUrlState()
}
window.addEventListener("popstate", restoreUrlState)
return () => window.removeEventListener("popstate", restoreUrlState)
}, [
filterIdsKey,
initialPagination,
onPaginationChange,
onSortingChange,
pagination,
sorting,
syncStateToUrl,
])
useEffect(() => {
if (!syncStateToUrl || !urlReady) return
const next = serializeDataExplorerUrlState(
new URLSearchParams(window.location.search),
{
search: searchValue,
filters: filterValues,
pagination: activePagination,
sorting: activeSorting,
},
filterIds,
initialPagination,
)
const query = next.toString()
const nextUrl = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`
window.history.replaceState(window.history.state, "", nextUrl)
}, [
activePagination,
activeSorting,
filterIds,
filterIdsKey,
filterValues,
initialPagination,
searchValue,
syncStateToUrl,
urlReady,
])
const filteredData = useMemo(
() =>
filterDataExplorerRows(
data,
searchValue,
searchConfig,
activeFilters,
filterValues,
),
[activeFilters, data, filterValues, searchConfig, searchValue],
)
const table = useTable({
features: dataExplorerFeatures,
columns,
data: filteredData,
getRowId,
state: {
pagination: activePagination,
rowSelection: activeSelection,
sorting: activeSorting,
},
onPaginationChange: changePagination,
onRowSelectionChange: changeSelection,
onSortingChange: changeSorting,
})
const resetPage = () =>
changePagination({ ...activePagination, pageIndex: 0 })
const hasActiveQuery =
Boolean(searchValue.trim()) || Object.values(filterValues).some(Boolean)
const resolvedStatus =
status === undefined || status === "success"
? data.length === 0
? "empty"
: "success"
: status
const rows = table.getRowModel().rows
const selectedCount = Object.values(activeSelection).filter(Boolean).length
const firstVisible =
filteredData.length === 0
? 0
: activePagination.pageIndex * activePagination.pageSize + 1
const lastVisible = Math.min(
filteredData.length,
firstVisible + rows.length - 1,
)
const clearQuery = () => {
setSearchValue("")
setFilterValues({})
resetPage()
}
const results =
filteredData.length === 0 && data.length > 0 ? (
(noResultsState ?? (
<div className="bg-card flex min-h-52 flex-col items-center justify-center gap-3 rounded-lg border p-8 text-center">
<div>
<p className="font-semibold">No matching results</p>
<p className="text-muted-foreground mt-1 text-sm">
Try changing or clearing the current search and filters.
</p>
</div>
<button
className="bg-background hover:bg-muted focus-visible:ring-ring min-h-11 rounded-md border px-4 text-sm font-semibold focus-visible:ring-2"
onClick={clearQuery}
type="button"
>
Clear search and filters
</button>
</div>
))
) : (
<>
<div className="hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-184 border-collapse text-left text-sm">
<thead className="bg-muted/50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
<th className="w-12 border-b p-4" scope="col">
<IndeterminateCheckbox
aria-label="Select all rows on this page"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
</th>
{headerGroup.headers.map((header) => {
const direction = header.column.getIsSorted()
return (
<th
aria-sort={
direction === "asc"
? "ascending"
: direction === "desc"
? "descending"
: undefined
}
className="border-b p-4 font-semibold"
key={header.id}
scope="col"
>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<button
className="focus-visible:ring-ring -m-2 inline-flex min-h-11 items-center gap-2 rounded px-2 text-left focus-visible:ring-2"
onClick={header.column.getToggleSortingHandler()}
type="button"
>
<table.FlexRender header={header} />
<span aria-hidden="true">
{direction === "asc"
? "↑"
: direction === "desc"
? "↓"
: "↕"}
</span>
</button>
) : (
<table.FlexRender header={header} />
)}
</th>
)
})}
</tr>
))}
</thead>
<tbody>
{rows.map((row) => (
<tr
className="hover:bg-muted/30 data-[selected=true]:bg-muted/50"
data-selected={row.getIsSelected()}
key={row.id}
>
<td className="border-b p-4">
<IndeterminateCheckbox
aria-label={`Select row ${row.id}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
</td>
{row.getAllCells().map((cell) => (
<td className="border-b p-4 align-middle" key={cell.id}>
<table.FlexRender cell={cell} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<ul className="grid list-none gap-3 p-0 md:hidden">
{rows.map((row) => (
<li
className="bg-card data-[selected=true]:ring-ring relative rounded-lg border p-5 pl-14 data-[selected=true]:ring-2"
data-selected={row.getIsSelected()}
key={row.id}
>
<IndeterminateCheckbox
aria-label={`Select row ${row.id}`}
checked={row.getIsSelected()}
className="absolute top-5 left-5"
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
{mobileCard(row.original)}
</li>
))}
</ul>
<div className="mt-4 flex items-center justify-between gap-4 max-sm:flex-col max-sm:items-stretch">
<p aria-live="polite" className="text-muted-foreground m-0 text-sm">
Showing {firstVisible}–{lastVisible} of {filteredData.length}
</p>
<div className="flex items-center gap-2 max-sm:grid max-sm:grid-cols-2">
<button
className="bg-background hover:bg-muted focus-visible:ring-ring min-h-11 rounded-md border px-4 text-sm font-semibold focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
type="button"
>
Previous
</button>
<span className="text-muted-foreground px-2 text-center text-sm max-sm:col-span-2 max-sm:row-start-1">
Page {activePagination.pageIndex + 1} of {table.getPageCount()}
</span>
<button
className="bg-background hover:bg-muted focus-visible:ring-ring min-h-11 rounded-md border px-4 text-sm font-semibold focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
type="button"
>
Next
</button>
</div>
</div>
</>
)
return (
<section className="min-w-0" data-slot="responsive-data-explorer">
{(searchConfig || activeFilters.length > 0) && (
<div className="mb-4 grid gap-3 sm:grid-cols-2 lg:flex">
{searchConfig ? (
<label className="grid min-w-0 flex-1 gap-2 text-sm font-medium">
{searchConfig.label ?? "Search"}
<input
className="bg-background focus-visible:ring-ring min-h-11 w-full rounded-md border px-3 outline-none focus-visible:ring-2"
onChange={(event) => {
setSearchValue(event.target.value)
resetPage()
}}
placeholder={searchConfig.placeholder ?? "Search records…"}
type="search"
value={searchValue}
/>
</label>
) : null}
{activeFilters.map((filter) => (
<label
className="grid min-w-40 gap-2 text-sm font-medium"
key={filter.id}
>
{filter.label}
<select
className="bg-background focus-visible:ring-ring min-h-11 rounded-md border px-3 outline-none focus-visible:ring-2"
onChange={(event) => {
setFilterValues((current) => ({
...current,
[filter.id]: event.target.value,
}))
resetPage()
}}
value={filterValues[filter.id] ?? ""}
>
<option value="">All {filter.label.toLocaleLowerCase()}</option>
{filter.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
))}
</div>
)}
{selectedCount > 0 ? (
<div
className="mb-4 flex items-center justify-between gap-4 rounded-lg border p-4"
data-slot="data-explorer-bulk-actions"
>
<p aria-live="polite" className="m-0 text-sm font-semibold">
{selectedCount} {selectedCount === 1 ? "row" : "rows"} selected
</p>
{bulkActions}
</div>
) : null}
<AsyncState
empty={emptyState ?? <DefaultEmptyState />}
errorValue={error}
onRetry={onRetry}
status={resolvedStatus}
>
{results}
</AsyncState>
{hasActiveQuery ? (
<p className="sr-only" aria-live="polite">
{filteredData.length} matching records
</p>
) : null}
</section>
)
}