Frontend Handbook
13Built

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 more

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

Start date
Salary (USD)

1,000 employees

NameDepartmentRoleLocationSalaryStart dateStatusRating
Rafael MüllerFinanceController 1London$90,000Oct 31, 2018Contractor3 out of 5
Emeka BianchiProductProduct Manager 9Singapore$175,500Mar 20, 2024Active3 out of 5
Ingrid WilliamsSalesSales Engineer 4Ho Chi Minh City$91,000Jun 2, 2013Active1 out of 5
Mehmet SøndergaardMarketingGrowth Marketer 3Berlin$89,500Mar 15, 2020Active2 out of 5
Linh FernándezSalesAccount Executive 5Hanoi$109,500Oct 5, 2012Active2 out of 5
Søren ÅbergPeoplePeople Ops Specialist 3Seoul$97,500Jan 4, 2020ActiveNo rating
Arjun O'BrienSalesSales Engineer 7Amsterdam$130,500Nov 4, 2020Active3 out of 5
Ana CarvalhoSupportSupport Specialist 12Toronto$143,000Dec 12, 2011Active4 out of 5
Rafael O'BrienDesignUX Researcher 1São Paulo$102,500Feb 17, 2025Active2 out of 5
Sara ItoProductProduct Manager 1São Paulo$102,000Feb 15, 2024Contractor3 out of 5
Isabel AndersenSupportSupport Specialist 8Ho Chi Minh City$116,000Jul 4, 2020Active3 out of 5
Łukasz CarvalhoPeoplePeople Ops Specialist 2Amsterdam$84,000Sep 17, 2023On leave4 out of 5
Linh FernándezDesignUX Researcher 6Amsterdam$142,500Apr 30, 2019Contractor3 out of 5
James SchmidtSalesSales Development Rep 5Hanoi$108,000May 23, 2014On leave2 out of 5
Linh NovakFinanceFinancial Analyst 5Toronto$124,000Aug 31, 2020Active3 out of 5
Arjun HansenPeoplePeople Partner 2São Paulo$88,500Jun 24, 2019Contractor2 out of 5
François GarcíaSalesSales Engineer 1Amsterdam$72,500Feb 26, 2020Active2 out of 5
Rafael CarvalhoProductProduct Analyst 4Ho Chi Minh City$125,000Feb 27, 2025Active1 out of 5
José CostaSalesSales Engineer 2Amsterdam$78,500Dec 29, 2025ActiveNo rating
Léa TanakaDesignProduct Designer 12São Paulo$189,500Jul 27, 2016Active3 out of 5
Ingrid WongSalesSales Development Rep 4Berlin$91,000Apr 11, 2024Active3 out of 5
François Van DijkMarketingMarketing Analyst 6São Paulo$126,000Aug 12, 2013Contractor3 out of 5
Chloé BianchiMarketingMarketing Analyst 12Hanoi$171,000Jun 30, 2022Active3 out of 5
Ben CarvalhoPeopleRecruiter 9Ho Chi Minh City$130,500Dec 29, 2016Active2 out of 5
Maya BianchiSalesSales Engineer 3Toronto$92,000Aug 29, 2017Active5 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.

1,000 employees. Not sorted. Page 1 of 40.
0 selected
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)