Choose a data mode
Use this guide after your table contract and columns are stable.
This is the canonical decision page for where table work happens. The downstream feature guides should inherit this choice instead of reopening it.
This page is really about three separate decisions:
- should the browser own the working row set, or should the server stay authoritative?
- if the server owns the rows, should users move through pages or through a continuous scroll?
- should the table mount the full loaded list, or virtualize it and mount only the visible window?
Keep those decisions separate in your head:
- local vs online decides where query semantics live
- pagination vs infinite decides how online users move through results
- virtualization decides how many loaded rows are mounted in the DOM
Start by choosing where query semantics live
Pick the place where filtering, sorting, grouping, and totals should really happen.
| Choose | When it fits | Main prop |
|---|---|---|
| Local mode | The browser can hold the full result set and client-side table behavior is acceptable. | data |
| Online mode | The backend should stay authoritative for query semantics, totals, or row windows. | online |
This is the highest-leverage choice. It matters more than whether the table later uses virtualization, preview, or saved views.
Path 1: choose local mode when the browser can own the rows
Use local mode when:
- the full dataset is bounded and cheap to preload
- filtering and sorting can happen in memory
- you want the shortest path to a responsive table
- the backend does not need to recompute totals, facets, or grouped output on every interaction
<Datatable
tableKey="customers"
columns={columns}
data={customers}
getRowId={(row) => row.id}
/>Local mode keeps the integration simple: your app fetches rows once, then the table handles search, filters, sorting, grouping, and selection in the browser.
Then decide whether to turn on virtualization
Local mode decides who owns query semantics. Virtualization only decides how many loaded rows are mounted in the DOM.
Keep virtualization off when:
- the table stays responsive with the full DOM mounted
- row height is simple and fairly stable
- users usually work with smaller result sets
Turn virtualization on when:
- the data is still reasonable to preload, but mounting every row feels sluggish
- rows contain heavier React cells, menus, badges, previews, charts, or nested layouts
<Datatable
tableKey="customers"
columns={columns}
data={customers}
getRowId={(row) => row.id}
virtualization={{ mode: "viewport", rowOverscanCount: 8 }}
/>Rule of thumb: if the browser can own the rows but the page feels heavy, try virtualization before moving query semantics to the server. If the problem is backend truth, totals, permissions, or data size, choose online mode instead.
Path 2: choose online mode when the server should stay authoritative
Use online mode when:
- the dataset is too large to preload safely
- permissions, tenant boundaries, or business rules must stay enforced on the server
- filtering, sorting, grouping, totals, or facets need backend truth
- users need server pagination or infinite loading
<Datatable
tableKey="customers"
columns={columns}
getRowId={(row) => row.id}
online={{
mode: "pagination",
queryKey: ["customers", workspaceId],
query: fetchCustomersPage,
}}
/>In online mode, the table still owns interaction state, but your backend owns query semantics. The table sends search, filters, sorting, grouping, and navigation input; the server returns render-ready rows plus totals.
That path usually also requires server work: query parsing, filtering, sorting, pagination or windowing, and stable row identity all need to be implemented on the backend.
Next, choose the online navigation model
If you choose online, pick the navigation pattern that matches the product experience.
Use pagination when page boundaries matter
Choose mode: "pagination" when users expect explicit pages, exact totals, and deliberate page movement.
online={{
mode: "pagination",
queryKey: ["customers", workspaceId],
pageSize: 50,
initialPageIndex: 0,
query: fetchCustomersPage,
}}This is usually the better default for back-office tables, audit surfaces, and workflows where users care about counts and repeatable pages.
Virtualization is often unnecessary here, especially when page sizes are moderate and rows are simple. Add it only when a single page still mounts enough heavy UI to feel slow.
Use infinite mode when users browse through a long list
Choose mode: "infinite" when users move through a continuous result set and page numbers add little value.
online={{
mode: "infinite",
queryKey: ["customers", workspaceId],
pageSize: 80,
prefetchRows: 160,
query: fetchCustomersWindow,
}}This works best when your backend can load stable row windows by offset and the product experience is centered on scrolling rather than paging.
Here, prefetchRows is an online data-loading option, not a virtualization option. It tells the table to fetch extra server rows around the current visible window so scrolling feels smoother.
In practice, infinite mode should usually be paired with virtualization. Once users can scroll through a long continuously loaded list, mounting everything defeats the point.
Keep online navigation and virtualization separate
Online mode decides how data is fetched. Virtualization decides how many loaded rows are mounted in the DOM.
<Datatable
tableKey="customers"
columns={columns}
getRowId={(row) => row.id}
online={{
mode: "infinite",
queryKey: ["customers", workspaceId],
pageSize: 80,
prefetchRows: 160,
query: fetchCustomersWindow,
}}
virtualization={{ mode: "viewport", rowOverscanCount: 8 }}
/>A few important boundaries:
online.mode: "infinite"is a server-loading strategyvirtualization.mode: "viewport"is a DOM-rendering strategyprefetchRowswarms server data around the visible rangerowOverscanCountmounts extra DOM rows around the viewport
Choose online mode for backend-owned query semantics, and choose virtualization for render-cost control.
Build a stable queryKey
Your queryKey is the table's dataset identity for online requests. It is not a React Query cache key. It should identify which dataset this table is querying, not which temporary view state the user is in.
queryKey: ["customers", workspaceId, segmentId]Only include segmentId when it is a fixed app scope outside the table controls.
For example, "enterprise" belongs in queryKey if the whole route is an Enterprise Customers page and the table can never show non-enterprise rows. If the user chooses Enterprise from a Plan or Segment filter, then it is table state instead and should not be in queryKey.
Good things to include:
- workspace or tenant IDs
- route or product scope
- a fixed dataset segment that the whole table is scoped to
Do not include things like page number, scroll offset, search text, filters, sorting, or grouping there. The table already sends those through the online query input.
Load online data with fetch
The simplest version is a plain async function that accepts the table's query input and returns the table's online response shape.
async function fetchCustomersPage(input: OnlineQueryInput): Promise<OnlineQueryResponse<Customer>> {
const response = await fetch("/api/customers/table", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(input),
})
if (!response.ok) {
throw new Error("Failed to load customers")
}
return response.json()
}
<Datatable
tableKey="customers"
columns={columns}
getRowId={(row) => row.id}
online={{
mode: "pagination",
queryKey: ["customers", workspaceId],
query: fetchCustomersPage,
}}
/>This is enough for a server-owned table. The table builds the request from current search, filters, sorting, grouping, pagination, and saved state.
This example uses POST so the table query can stay as JSON. That is practical for structured filter, sorting, grouping, and pagination input, and it avoids long encoded query strings. POST is not required; if your API prefers GET, encode the same input into the URL and return the same online response shape.
Add React Query when you want cached online requests
After the plain fetch version, you can cache the table request with libraries like React Query. This can make sense for datatables because users often move between tabs, routes, and detail pages, then expect already-loaded rows to be available without another network request.
Keep the table's online.queryKey as the dataset identity, and use React Query's queryKey for the cached response.
import { useQueryClient } from "@tanstack/react-query"
function CustomersTable({ workspaceId }: { workspaceId: string }) {
const queryClient = useQueryClient()
return (
<Datatable
tableKey="customers"
columns={columns}
getRowId={(row) => row.id}
online={{
mode: "pagination",
queryKey: ["customers", workspaceId],
query: (input) =>
queryClient.fetchQuery({
queryKey: ["customers-table-response", workspaceId, input],
queryFn: () => fetchCustomersPage(input),
staleTime: 30_000,
}),
}}
/>
)
}Use this shape when cache reuse, request deduping, retries, or background refresh are part of the app shell. The table still owns the online query input; React Query owns the transport cache around that input.
The table interface is agnostic to the fetching library. You can use the same online.query contract with fetch, tRPC, gRPC, Apollo Client, or another data client as long as the function returns the table's online response shape.
Reuse prefetched first-page data safely
If a route already loaded the first online result, pass it through initialData and guard it with initialDataQueryState.
online={{
mode: "pagination",
queryKey: ["customers", workspaceId],
initialData,
initialDataQueryState,
query: fetchCustomersPage,
}}That guard matters when persisted state or URL state changes the final query before the table settles. Without it, users can briefly see stale rows from the wrong filter or view.
Use this quick decision path
Choose local mode if all of these are true:
- the full row set is reasonably bounded
- browser-side filtering and sorting are acceptable
- backend-owned totals or grouped summaries are not required
Choose online mode if any of these are true:
- server rules must stay authoritative
- the result set is large or continuously changing
- pagination or infinite row windows are part of the product design
- online grouping, totals, or facets should match backend truth
Then choose rendering with these defaults:
- local mode: depends on row count and rendering cost
- online + pagination: usually do not start with virtualization
- online + infinite: usually do virtualize
Verify the choice before adding features
Before you move on, confirm that:
- row identity still comes from a stable domain ID
- the chosen mode matches where query semantics should live
- your
queryKeymatches the dataset identity - online query functions can return rows, totals, and grouped output accurately if needed
- virtualization settings are being treated as rendering controls, not data-loading controls
- local mode has an explicit virtualization decision instead of inheriting the default accidentally
- downstream feature guides in your app can now assume this decision is settled