Advanced Data Grid
Sorting, filtering, pagination, column resizing, and row selection.
How it works
A data grid that stays correct as it grows features: several sort keys at once, filters that combine, pages that stay valid when the data shrinks, columns you can resize, and a selection that survives all of it, including "select all 2,000 matching rows" the browser never downloaded. The same grid can run its query in the browser or on the server; switch between them to see what each costs.
- Derive the view: the rows on screen are filter → sort → page of the data, computed from the view state, never stored and patched.
- Select by id, not by row: a set of ids survives sorting and paging; “all matching” is a rule (this filter, except these ids), not a list.
- One query, two places: the browser and the mock API call the same pure function, so both modes always show the same rows.
- One tab stop: the grid is a single stop on the page; arrow keys move between cells, and keys on the focused cell sort, select and resize.
Read moreShow less
Where you'd use it
- Admin and back-office screens: users, orders, invoices, audit logs.
- Anything with bulk actions (“archive these 37”), where the selection must be exactly what the user sees.
- Reports people slice by several columns and share as links.
How we built it
The view is plain data: up to three sort keys, a search string, column filters, page and page size. One pure function, queryEmployees, turns (rows, view, selection) into a page: it folds text (case, accents, extra spaces) and keeps rows where every search word appears in a text column and every filter holds, sorts with a comparator chain, and slices the page, clamping it to the last page so a shrinking result never shows "Page 7 of 2". Text compares with Intl.Collator (numeric, accent-aware), so “Engineer 2” comes before “Engineer 10” and “Ömer” sorts near “Omar”; empty values sort last in both directions, and the id is the final key, so equal rows never swap between renders. Impossible ranges (min above max) are ignored instead of returning nothing.
A framework-free GridController owns the view, the selection and the processing mode; React subscribes with useSyncExternalStore. It keeps the last applied result on screen and marks it pending (dimmed, aria-busy) until the result for the current view replaces it. In browser mode it downloads the dataset once per size (cached at module level) and runs the query synchronously, timing it with performance.now(). In server mode it POSTs the view to the mock API, which runs the same function: search typing waits 300 ms, every send increments a run id and aborts the previous request, and only the latest run may apply its response, so a slow sort can never overwrite a newer one. The page server-renders the first page from the URL with the same function, so there is no loading flash and only 25 rows cross to the client.
Selection is either a set of ids or { all, filterKey, except, total }. Checking every row of a page offers “Select all N matching”; deselecting a row then adds it to except, so the count is total − except without listing 10,000 ids. When the filters change under that rule, browser mode turns it into the explicit ids it covered (it knows every row); server mode can't, so it clears it and says why. Shift+click applies the clicked row's new state to the range from the last toggled row. The server reports which selected ids match the current filters, which is how “3 hidden by filters” works without downloading rows.
The table uses table-layout: fixed with a <colgroup> of pixel widths, so widths are exact and cheap, and it scrolls inside its own box instead of squeezing columns. The resize handle uses pointer capture, which keeps move events coming when the pointer leaves the handle and ends the drag on lostpointercapture (release or cancel); rows are memoized, so a drag re-renders only the header. The keyboard follows the WAI-ARIA data grid pattern: one cell has tabIndex=0 (roving tabindex), arrows, Home/End and Ctrl+Home/End move, PageUp/PageDown change page, and when the focused cell is replaced (new page, fewer rows) focus moves to the same position instead of falling to the page body. aria-rowcount and aria-rowindex tell screen readers where a row sits in the whole result, aria-sort marks the primary sort column, and polite live regions announce the result, the selection count and new column widths.
Best practices
Do: Keep the view (sort, filters, page) as state and derive the visible rows from it.
Avoid: Storing the sorted, filtered page in state and patching it on every change: it drifts from the data.
Do: Make the sort stable: end the comparator chain with a unique key, and put empty values last in both directions.
Avoid: a.localeCompare(b) alone: equal rows reorder between renders, and “Item 10” sorts before “Item 2”.
Do: Reset to page 1 when sort or filters change, and clamp the page to the last one.
Avoid: Keeping page 7 after filtering to 2 pages: an empty grid that looks like “no results”.
Do: Select by id, and model “select all matching” as filter + exceptions.
Avoid: Selecting by row index, or fetching every matching id just to tick them.
Do: In server mode, let only the latest request update the grid (run id), and abort the rest.
Avoid: setRows(await fetch(...)) per click: a slow earlier sort lands last and wins.
Do: Give the grid one tab stop and move focus with arrow keys; restore focus when its cell is re-rendered.
Avoid: A tab stop per cell or checkbox: hundreds of Tab presses to get past one page.
Do: Use table-layout: fixed with explicit widths, and let the grid scroll in its own box.
Avoid: Auto layout with resizable columns: every content change re-measures and columns jump.
Try it with the demo controls
- Sort, filter and page
- “In the browser” downloads the dataset once (see the stats line), then every change is a few milliseconds with 0 requests. “On the server” sends one small request per change and dims the rows while it waits.
- Dataset
- 10,000 employees is about 1.7 MB to download in browser mode, versus under 20 KB per page on the server. Sorting 10,000 rows in the browser still takes only a few milliseconds.
- Server latency
- Set 1,000 ms in server mode and click Name and Salary quickly several times: requests overtake each other, yet the rows always match the header arrows.
- Failure rate
- At 100%, the next change fails: the previous rows stay, with an error and Retry. Set it back to 0 and Retry.
- Headers
- Click to cycle ascending, descending, unsorted. Shift+click adds a second and third key; the small numbers show priority.
- Select all matching
- Tick the header checkbox, then “Select all N matching”; go to the last page: those rows are selected too. Untick one: the count drops by one.
- Filters with a selection
- Select a few rows, then filter them out: “hidden by filters” counts them. With “all matching” selected, change a filter: browser mode keeps exactly those rows, server mode clears the selection and tells you why.
- Column edges
- Drag a header's right edge; double-click it to reset. With a header focused, Alt+→ widens by 10 px.
- Keyboard
- Tab into the grid, move with arrows, Space selects a row, Shift+Space selects a range, PageDown changes page and keeps your column.
- URL
- Sort and filter, then reload: the same view renders on the first paint. Back leaves the page instead of undoing filters.
Trade-offs and limits
- Browser mode is instant after the download but pays for the whole dataset up front and holds it in memory; past tens of thousands of rows (or with sensitive data) the server is the only option.
- Server mode costs a round trip per change and needs a debounce for typing; it has no cache here, so going back a page refetches.
- “All matching” in server mode can't survive a filter change: the server's matching set changed, and turning the rule into ids would mean downloading them.
- Paging keeps the DOM small; showing thousands of rows at once needs virtualization, which practice 14 covers.
- Column widths last for the visit; persisting them means storing them per user (local storage or the server) and versioning them with the columns.
- Filters are single-value per column, and there is no column reordering, pinning or cell editing: each is a pattern of its own.
Demo controls
1,000 employees
| Name | Department | Role | Location | Salary | Start date | Status | Rating | |
|---|---|---|---|---|---|---|---|---|
| Rafael Müller | Finance | Controller 1 | London | $90,000 | Oct 31, 2018 | Contractor | 3 out of 5 | |
| Emeka Bianchi | Product | Product Manager 9 | Singapore | $175,500 | Mar 20, 2024 | Active | 3 out of 5 | |
| Ingrid Williams | Sales | Sales Engineer 4 | Ho Chi Minh City | $91,000 | Jun 2, 2013 | Active | 1 out of 5 | |
| Mehmet Søndergaard | Marketing | Growth Marketer 3 | Berlin | $89,500 | Mar 15, 2020 | Active | 2 out of 5 | |
| Linh Fernández | Sales | Account Executive 5 | Hanoi | $109,500 | Oct 5, 2012 | Active | 2 out of 5 | |
| Søren Åberg | People | People Ops Specialist 3 | Seoul | $97,500 | Jan 4, 2020 | Active | No rating | |
| Arjun O'Brien | Sales | Sales Engineer 7 | Amsterdam | $130,500 | Nov 4, 2020 | Active | 3 out of 5 | |
| Ana Carvalho | Support | Support Specialist 12 | Toronto | $143,000 | Dec 12, 2011 | Active | 4 out of 5 | |
| Rafael O'Brien | Design | UX Researcher 1 | São Paulo | $102,500 | Feb 17, 2025 | Active | 2 out of 5 | |
| Sara Ito | Product | Product Manager 1 | São Paulo | $102,000 | Feb 15, 2024 | Contractor | 3 out of 5 | |
| Isabel Andersen | Support | Support Specialist 8 | Ho Chi Minh City | $116,000 | Jul 4, 2020 | Active | 3 out of 5 | |
| Łukasz Carvalho | People | People Ops Specialist 2 | Amsterdam | $84,000 | Sep 17, 2023 | On leave | 4 out of 5 | |
| Linh Fernández | Design | UX Researcher 6 | Amsterdam | $142,500 | Apr 30, 2019 | Contractor | 3 out of 5 | |
| James Schmidt | Sales | Sales Development Rep 5 | Hanoi | $108,000 | May 23, 2014 | On leave | 2 out of 5 | |
| Linh Novak | Finance | Financial Analyst 5 | Toronto | $124,000 | Aug 31, 2020 | Active | 3 out of 5 | |
| Arjun Hansen | People | People Partner 2 | São Paulo | $88,500 | Jun 24, 2019 | Contractor | 2 out of 5 | |
| François García | Sales | Sales Engineer 1 | Amsterdam | $72,500 | Feb 26, 2020 | Active | 2 out of 5 | |
| Rafael Carvalho | Product | Product Analyst 4 | Ho Chi Minh City | $125,000 | Feb 27, 2025 | Active | 1 out of 5 | |
| José Costa | Sales | Sales Engineer 2 | Amsterdam | $78,500 | Dec 29, 2025 | Active | No rating | |
| Léa Tanaka | Design | Product Designer 12 | São Paulo | $189,500 | Jul 27, 2016 | Active | 3 out of 5 | |
| Ingrid Wong | Sales | Sales Development Rep 4 | Berlin | $91,000 | Apr 11, 2024 | Active | 3 out of 5 | |
| François Van Dijk | Marketing | Marketing Analyst 6 | São Paulo | $126,000 | Aug 12, 2013 | Contractor | 3 out of 5 | |
| Chloé Bianchi | Marketing | Marketing Analyst 12 | Hanoi | $171,000 | Jun 30, 2022 | Active | 3 out of 5 | |
| Ben Carvalho | People | Recruiter 9 | Ho Chi Minh City | $130,500 | Dec 29, 2016 | Active | 2 out of 5 | |
| Maya Bianchi | Sales | Sales Engineer 3 | Toronto | $92,000 | Aug 29, 2017 | Active | 5 out of 5 |
Arrow keys move between cells. On a column header, Enter sorts, Shift+Enter adds a sort key, Alt+Left and Alt+Right resize the column. On a row, Space selects it, Shift+Space selects a range. Page Up and Page Down change page.
- Last change ran:
- on the server (first paint)
- requests this session:
- 0
Keyboard shortcuts
- Tab
- Enter or leave the grid (one tab stop)
- Arrow keys
- Move between cells
- Home / End
- First / last cell of the row (with Ctrl: of the page)
- Page Up / Page Down
- Previous / next page
- Enter or Space on a header
- Sort (Shift: add as next sort key)
- Alt + ← / → on a header
- Resize by 10 px (Shift: 50 px); Alt + 0 resets
- Space on a row
- Select or deselect (Shift: the range from the last one)