← Default view
Sheet S-03 · Server-driven data · React data grids 2026

Who owns the
state machine?

A schematic of one line: the client/server boundary. Every library on this sheet draws the same boundary — they differ only in how much of the fetch/cache/lazy-load machinery they place on which side of it, and how much of it they leave for you to fabricate on site.

DepthSurvey
Rev2026-07-13
Sources31
Read8 min
Key plan Key plan: client/server wiring diagram

General notes — decision

If the server owns pagination + sorting + filtering and grouping/aggregation, only AG Grid's Server-Side Row Model [12] and MUI X's Data Source [18] are first-class engines that fetch, cache and lazy-load for you — both paywalled at the group/aggregate tier [13][20]. TanStack Table ⭐ 28.2k gives you manual* opt-outs, not an engine: you own the state, the fetch, the debounce and the page-reset [1][5] — pair it with TanStack Query ⭐ 50k and it's excellent, but the wiring is yours. Mantine React Table ⭐ 1.1k inherits exactly TanStack Table v8's manual model and adds nothing server-side [24].

Elevation — the boundary  ·  two philosophies, one dashed line

A · Opt-out model

TanStack Table, Mantine React Table — you flip manualPagination / manualSorting / manualFiltering and the table stops doing the work. It does not start doing it on the server. It just believes whatever data array you hand it [1][2][3].

Client — browser
pagination / sorting / filter stateyou
page-reset on filter changeyou
keystroke debounceyou
query key + request cacheTanStack Query
race safety / out-of-order responsesyou
fetchRows()you
useReactTable — renders rows, believes datalib
Client / Server
──▶
◀──
Server — your API
GET /rows?page&sort&filteryou
returns { rows, total }you
grouping / aggregation — no wire format exists [4]gap

B · Datasource model

AG Grid SSRM, MUI X Data Source — you implement one getRows(params) callback. The grid owns the request lifecycle, block cache, scroll-triggered fetching, loading skeletons and error surface [12][14][18].

Client — the grid is the engine
Grid-owned machinerysealed unit
block cache — cacheBlockSize 100, LRU evictiongrid
scroll-triggered block fetchgrid
debounce — blockLoadDebounceMillis / 500 ms throttlegrid
cache purge + refetch on sort/filter changegrid
loading skeletons + error surfacegrid
sort / filter / group / pivot modelsgrid
getRows(params) — the one seam you fabricateyou
Client / Server
──▶
◀──
Server — receives the models
startRow / endRowreq
sortModel · filterModelreq
rowGroupCols · groupKeysreq
valueCols · pivotColsreq
aggregationModel (MUI)req
filter · sort · group · pivot · aggregate execute here [12]sql
Library owns it You build it Behind a paywall Not supported
Parts schedule  ·  which side of the line each part sits on
AG Grid⭐ 15.5k · SSRM
Server pagination
YoumanualPagination + you supply rowCount/pageCount [1]
Gridblock-based, grid-driven [12]
GridpaginationMode="server" or Data Source [21]
YoumanualPagination + rowCount [24]
Server sort / filter
YoumanualSorting / manualFiltering [2][3]
GridsortModel/filterModel in request; cache auto-purged [15]
GridsortingMode/filterMode="server" [22]
Yousame as TanStack v8 [25]
Server grouping / aggregation
GapmanualGrouping exists, but "not currently many known easy ways" [4]
Enterpriselazy group expansion, pivot, aggregation [13]
PremiumaggregationModel + getAggregatedValue() [20]
Noneinherits TanStack's gap [4]
Infinite / lazy scroll
YouTable + Query useInfiniteQuery + Virtual [7]
GridInfinite Row Model (free) or SSRM (Enterprise) [13]
ProlazyLoading prop [19]
Nonebuild it
Built-in request cache
Youbring TanStack Query
Gridblock cache, cacheBlockSize 100, maxBlocksInCache LRU [15]
GridGridDataSourceCacheDefault, 5-min TTL, swappable [18]
Nonebring your own
Built-in scroll debounce
Nonehand-rolled
GridblockLoadDebounceMillis [15]
Grid500 ms viewport-loading throttle [19]
Nonehand-rolled
Renders in a Server Component
No'use client'
No'use client' — depends on window/document [16]
No'use client'
No'use client'
Cost of server mode
Freeyour time
Enterpriselicence required for SSRM [13]
MIT → PremiumMIT for basic Data Source [23]; Pro for lazy load, Premium for aggregation [19][20]
Freeyour time

⚠ Restricted zone — the paywall sits on the boundary

The features that move work across the line are exactly the features that are gated. Grouping and aggregation on the server are not something you can grow into on a free tier — they are the purchase.

AG Grid · Community
Infinite Row Model
Free — but "aggregation and grouping are not available in infinite scrolling" [15]
AG Grid · Enterprise
Server-Side Row Model
Any grouped server table means SSRM, and SSRM means Enterprise [13]
MUI X · Community (MIT)
paginationMode/sortingMode/filterMode="server", basic dataSource
Free — v8 moved Data Source into Community [23]
MUI X · Pro
lazyLoading — viewport skeletons + infinite scroll
Paid tier [19]
MUI X · Premium
Server-side aggregation, row grouping, tree data
Top tier [20]
TanStack · always free
Server grouping
Not sold, not shipped — "you will need to do lots of custom cell rendering to make this work" [4]
Open defect · #4797

Fault note — the page that never resets

Turn on manualPagination and autoResetPageIndex is switched off for you [1]. Nothing in the table connects a filter change to the page index. The failure is silent — no error, no empty-state, just an empty table.

Step 01User is on page 1000
Step 02User types a filter
Step 03pageIndex stays at 999
Step 04Request goes out for page 1000 of the filtered set
ResultServer returns []. Table renders empty. User concludes the filter matched nothing.

Open since v8 — TanStack Table issue #4797 ⭐ 28.2k [5]. The fix is yours to fabricate: wrap setFilters / setSorting so they also setPagination(p => ({ ...p, pageIndex: 0 })) — in the same transaction, so only one query key ever exists.

Details  ·  the seam, drawn at full scale
Detail A — opt-out wiringTanStack Table
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 });
const [sorting, setSorting]       = useState<SortingState>([]);
const [filters, setFilters]       = useState<ColumnFiltersState>([]);

const q = useQuery({
  queryKey: ['rows', pagination, sorting, filters],  // the key IS the query
  queryFn: () => fetchRows({ pagination, sorting, filters }),
  placeholderData: keepPreviousData,             // no flash-to-empty
});

const table = useReactTable({
  data: q.data?.rows ?? [],
  columns,
  getCoreRowModel: getCoreRowModel(),
  manualPagination: true, manualSorting: true, manualFiltering: true,
  rowCount: q.data?.total,
  state: { pagination, sorting, columnFilters: filters },
  onPaginationChange: setPagination,
  onSortingChange: setSorting,
  onColumnFiltersChange: setFilters,
});
Not solved by this snippet: page-reset, keystroke debounce [31], and grouping [4]. pageCount: -1 for unknown totals makes getCanNextPage() always return true [1].
Detail B — datasource wiringAG Grid SSRM
const datasource: IServerSideDatasource = {
  getRows: params => {
    const res = server.getData(params.request);
    res.success
      ? params.success({ rowData: res.rows, rowCount: res.total })
      : params.fail();
  },
};
That is the whole seam [14]. Everything else — block cache, scroll fetch, debounce, purge-on-sort — is behind the boundary of the grid [15].

⚠ Query friction: getRows is a plain callback, not a component, so you cannot call useQuery inside it. Use useQueryClient() in the component and qc.fetchQuery(...) inside getRows, memoise the datasource, and put every request param in the key [17]. You are then running two caches — decide which is authoritative before you build.
Tolerances  ·  debounce ≠ race safety

React's own docs are blunt: "network responses may arrive in a different order than you sent them" — and then tell you not to hand-roll the fix at all, but to use TanStack Query or SWR [30].

  1. Debouncing a filter input only reduces how many requests fire. If a request outlives the debounce window, the stale response can still land last and overwrite the fresh one [30][31].
  2. Putting { pagination, sorting, filters } into the query key structurally removes the race: each parameter combination is a distinct cache entry, so a late response writes into its own key, not the visible one [8].
  3. Debounce the state update that feeds the key, not the fetch — and always reset pageIndex in the same transaction as the filter/sort change [5].
  4. The datasource grids sidestep most of this by construction: AG Grid purges and refetches on sort/filter change [15], MUI throttles viewport requests to 500 ms [19].
  5. Query v5 removed keepPreviousData and isPreviousData; write placeholderData: keepPreviousData and read isPlaceholderData [9]. Gate the Next button on isPlaceholderData || !data.hasMore [8].
  6. Row-count trap (MUI): "if the value rowCount becomes undefined during loading, it will reset the page to zero" — memoise it across fetches [21].
Section  ·  cursor vs offset, and how the grid infers it
Offset backend — a total existsViewport loading
Known rowCount → MUI picks viewport loading: skeleton rows, fetch when a skeleton enters the render window, 500 ms request throttle [19]. In TanStack land this is a plain useQuery keyed on the page.
Cursor backend — no cheap totalInfinite loading
Hand the grid rowCount = -1 (or undefined) plus paginationMeta.hasNextPage or estimatedRowCountinfinite loading: fetch at scroll-end, stop when the server returns nothing [19][21]. In TanStack land: useInfiniteQuery + getNextPageParam returning the server's nextCursor, with maxPages to cap retained pages [10].

SWR does the same job with one option — keepPreviousData: true keeps prior data across key changes [11] — but has no equivalent of Query's imperative fetchQuery escape hatch, so for AG Grid SSRM and MUI Data Source, Query is the better fit.

Site constraint  ·  no grid crosses into a Server Component

All four need 'use client', and once a file is marked, "all of its imports and the components it directly renders are included in the client bundle" [29]. AG Grid says it outright: ag-grid-react "depends on some browser-specific APIs (e.g. window/document) and can not be rendered server-side" [16]. So the boundary you draw in the App Router is URL-as-table-state:

  1. Server Component page.tsx parses searchParams (a Promise since Next 15) via createSearchParamsCache and passes typed page/sort/filter down without prop-drilling [26].
  2. It fetches server-side and streams rows into a thin 'use client' table shell.
  3. The client shell writes state back with nuqs ⭐ 10.7k useQueryState; shallow: false opts into "notifying the server (to re-render Server Components on the app router)", with throttleMs to rate-limit — the hook's state updates instantly regardless, only the URL write and server round-trip are throttled (default 50 ms; ~340 ms on Safari) [27]. That throttleMs is your debounce for RSC round-trips.

Reference implementation: shadcn-table ⭐ 6.2k — TanStack Table + Next.js with "server-side pagination, sorting, and filtering", Notion-style advanced filters and virtualized infinite scroll [28]. AG Grid and MUI X can live inside this too, but their state lives in the grid's own model objects, so you must serialise sortModel/filterModel into the URL yourself — the URL-state ergonomics favour the headless option.

Selection chart  ·  specify by the shape of your server
Your situationSpecified component
Flat rows, offset or cursor paging, sort + filter, no grouping TanStack Table + TanStack Querymanual* + placeholderData: keepPreviousData [1][8]
Table state must live in the URL / RSC + App Router TanStack Table + nuqs — grid-model libraries fight you here [26][28]
Server-side grouping, pivot, aggregation over millions of rows AG Grid SSRM (Enterprise) [12][13]
Already on MUI, want server grouping/aggregation without writing the engine MUI X Data SourcePro for lazy load, Premium for aggregation [19][20]
Mantine design system, simple server paging Mantine React Table — but it is a TanStack v8 wrapper with a stale cadence (last tagged release Oct 2023) [25]. ⚠ No server-side story of its own.

Revision note — v9. TanStack Table v9 has been in beta since 7 June 2026 (TanStack Store-backed state, plugin-registered features, large memory and type-instantiation wins). The announcement says nothing about improving server-driven mode — the manual* opt-out philosophy is unchanged [6]. Adopting v9 in 2026 means adopting a beta.

Sheet titleServer-driven data — which React table actually does the work for you
DrawingCanonical page
ProjectReact tables 2026
ArchiveAtlas