Home/ Cookbook/ Lists/ Filterable data table

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.

Lists 2 primitives · 3 blocks · 1 pattern ~40 min React · @corelithzw/react

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.

Huchu · Admin

Users

Active 18 Invited 3 All 24
Name · Email · Role
Tendai M. · tendai@
Tendai N. · tendai.n@
1 of 4 ‹ ›
01 Filtered + searched
Huchu · Admin

Users

Active
Loading…
▓▓▓▓ · ▓▓▓▓▓▓
▓▓▓▓ · ▓▓▓▓▓▓
▓▓▓▓ · ▓▓▓▓▓▓
02 Loading — skeleton rows
Huchu · Admin

Users

Active
No users match.
Clear filters
03 Zero results — clear is one tap

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

01

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.

One filters object → one URL query string.
Every change but page/sort resets to page 1 — otherwise users land on an empty page 4 after typing a search.
replaceState not pushState — typing shouldn't bloat history.
Step 1 · useFilters hook@corelithzw/react
02

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.

Search debounce: 300ms. Chips: immediate.
AbortController guarantees the latest filters win even if an older request is slower.
Step 2 · Data hook
03

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.

tendai
Active 18
Invited 3
All 24
Step 3 · Toolbar
04

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 === 0EmptyState with a real "Clear filters" action.
Per-row actions hang off a kebab in the last column.
Step 4 · Table render
05

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.

A saved view is just { name, qs }.
Apply = parse qs back into filters, fetch runs naturally.
Step 5 · Saved views

Final composition

The UsersTable page, assembled. URL-sync, debounced search, server fetch, loading/empty states, bulk actions, saved views.

Users

My open
+ Save view
tendai
Active 18
Invited 3
24 total
NameEmailRole
Tendai Moyotendai@mukamba.coOwner
Tendai Ncubetendai.n@mukamba.coManager
UsersTable.tsx@corelithzw/react

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. DataTable renders sort triggers as <button> with aria-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 loading renders a skeleton with aria-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. FilterChips renders the right ARIA — don't substitute styled divs.
  • 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.