Build a filterable data table whose filters live in the URL
By the end of this recipe you'll have a paginated, sortable, server-side-filtered table with chip filters, a debounced search, row + bulk actions, saved views, and every filter mirrored in the query string so a shared link recreates the exact view.
Overview
A data-table view with a single source of truth: the URL. Search, chips, sort and page all live in the query string, so refreshing or sharing a link recreates the exact same screen.
Operators live in these tables — admin users, finance invoices, ops job-cards. The first thing a senior staffer does on Monday is pull up "everything overdue, assigned to me, sorted by oldest" — and they want to bookmark it. If the filter state lives in component state only, that bookmark doesn't survive a refresh and the workflow falls apart. URL-sync turns saved views into one-liners: a view is just a stored query string.
The opinionated bit is server-side everything. Client-side filter + sort feels great on 50 rows and falls over on 5,000. Run filter, sort and paginate on the server, return a count, and render exactly the page you asked for. Loading and empty states are first-class — not afterthoughts.
Skip this recipe if your dataset is fixed and small (under a few hundred rows that never grow). Plain useState + client-side filter() is fine, and you can ignore URL-sync.
What you'll build
Three states: a normal filtered view, a loading view, and a zero-results state with one-click clearing.
Users
Users
Users
Clear filters
You can see this live in
Portal demos that wire this table in context. Open one to try the chip filters and search.
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: DataTable, FilterChips, RowCard, Stack, Input, Button, EmptyState, Toast. We also introduce the useFilters hook — it owns the URL sync and the debounce.
Step-by-step build
One filter object, URL-synced — that's the source of truth
Don't keep search, status, sort and page in four separate useStates. Hoist them into one filters object and write it to the URL. The browser's back button now navigates filter history for free, and a shared link recreates the exact view.
filters object → one URL query string.replaceState not pushState — typing shouldn't bloat history.Fetch on filter change — debounce only the search, not the chips
Chip clicks should fire immediately; the user just decided. Search keystrokes should debounce 300 ms; otherwise you fire a request per letter. A simple AbortController cancels stale fetches so the last response always wins.
AbortController guarantees the latest filters win even if an older request is slower.Render the toolbar: search + chips + bulk-action zone
The toolbar is sticky on scroll and morphs when rows are selected — chips collapse and bulk actions take their place. That's not decoration; it tells the user the toolbar is now acting on the selection, not the whole list.
Render the table — and never skip the empty state
Three render branches: loading, rows.length === 0, and the table itself. The empty state must offer "Clear filters" — otherwise users with a typo end up convinced the system is broken. Loading shows skeleton rows, not a spinner; the user can already see the shape of what's coming.
loading → skeleton rows inside DataTable.rows.length === 0 → EmptyState with a real "Clear filters" action.Saved views = stored query strings
Power users want "My open requests" and "All Park Centre, sorted by oldest" one tap away. A saved view is just a name + a query string in localStorage; clicking it calls setFilters(fromQuery(view.qs)). No new state shape, no view-aware fetcher — the URL is the contract.
{ name, qs }.Final composition
The UsersTable page, assembled. URL-sync, debounced search, server fetch, loading/empty states, bulk actions, saved views.
Users
Variations
Three forks. Each a small diff off the final composition.
Infinite scroll (no pager)
Phone-first feeds (notifications, timeline). Swap pagination for an IntersectionObserver that bumps page.
const loader = useRef<HTMLDivElement>(null);
useEffect(() => {
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) setFilters({ page: filters.page + 1 });
});
if (loader.current) io.observe(loader.current);
return () => io.disconnect();
}, [filters.page]);
Client-only (no server)
Fixed dataset, no API. Skip useTableData; filter and sort in useMemo. URL-sync still works.
const rows = useMemo(() => {
return ALL_USERS
.filter(u => matches(u, filters))
.sort(byCol(filters.sort))
.slice((filters.page - 1) * 25, filters.page * 25);
}, [filters]);
const total = ALL_USERS.length;
Multi-select chips
Status is a set, not a single value. FilterChips takes multiple and an array of strings.
type Filters = { ..., status: string[] };
<FilterChips
multiple
value={filters.status}
onChange={(status) =>
setFilters({ status: status as string[] })}
options={...}
/>
Accessibility
What this recipe takes care of for you.
- Search input has a real label. The visible placeholder is supplemented by
aria-label="Search users"so a screen reader doesn't say "edit text". - Sortable headers are buttons.
DataTablerenders sort triggers as<button>witharia-sort="ascending"/"descending"so the current column and direction are announced. - Bulk-mode toolbar announces itself. Toggling selection swaps the toolbar; the new region uses
role="status"+aria-live="polite"so screen-reader users hear "2 selected". - Loading state is honest.
DataTable loadingrenders a skeleton witharia-busy="true"on the table so assistive tech announces "loading users" instead of reading garbage rows. - Empty state has an action, not just text. "No results" is useless without "Clear filters" — keyboard and screen-reader users get a real button, not a styled link.
- Filter chips are real radios under the hood. Arrow keys move between chips; Space toggles.
FilterChipsrenders the right ARIA — don't substitute styleddivs. - Selection persists across pages. When the user pages forward, the previous page's selection is kept. The selection summary in the toolbar always tells the truth.