Skip to main content

Server-side data

For large datasets, let your backend handle sorting, filtering, and pagination — the grid sends the current state and renders whatever rows you give it back (it does not re-sort/filter/paginate server data on the client).

There are two ways to wire this up. Pick whichever fits how your app already fetches data:

ApproachYou provideThe grid…Best when
A. onFetchDataa fetch callbackcalls it (debounced, race-safe) and stores the resultthe grid owns the data lifecycle
B. Controlled datadata + rowCount + onDataStateChangerenders your data, tells you when to refetchyou already use React Query / SWR / Redux

Both require telling the grid the data is server-managed — either dataMode="server" (all three) or the granular modes.


Approach A — onFetchData (grid-driven)

Set dataMode="server" and provide onFetchData. The grid calls it on mount and whenever the server-delegated state changes, returning the page to render plus the grand total.

<DataTable
columns={columns}
dataMode="server"
enablePagination
enableSorting
enableGlobalFilter
enableColumnFilter
onFetchData={async (filters, meta) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(buildQuery(filters)), // see "The request payload" below
});
const json = await res.json();
return { data: json.rows, total: json.total }; // <- total drives the pager
}}
/>
  • Debounced + race-safe. Rapid changes (typing in search) collapse into one call (300 ms), and only the latest response is applied — stale responses are dropped automatically. You don't need to cancel requests yourself. (There is no signal on meta; if you want hard cancellation, manage your own AbortController keyed off meta.reason.)
  • No rowCount needed in this mode — the total you return is authoritative.
  • meta tells you why the fetch fired: { reason: 'initial' | 'state-change' | 'refresh' | 'reload' | 'reset', force?, delay? }.

The request payload (what filters contains)

filters is everything the server needs to build the query. This is the shape you map to your API:

interface TableFilters {
pagination?: { pageIndex: number; pageSize: number };
sorting?: { id: string; desc: boolean }[];
globalFilter?: string; // the toolbar search box
columnFilter?: {
filters: {
id: string; // rule id
columnId: string; // which column
operator: string; // 'contains' | 'equals' | 'greaterThan' | 'in' | … (per column type)
value: any;
columnType?: string; // 'text' | 'number' | 'date' | 'select' | …
}[];
logic: 'AND' | 'OR'; // how the rules combine
};
}

The advanced column filters arrive already applied (the filters array + logic); the pendingFilters the user is still editing are not sent. See Filtering for the operators each column type exposes.

Mapping filters to your backend

A small translator keeps onFetchData clean — read each field and emit your query (REST params, a JSON body, GraphQL variables, SQL, …):

function buildQuery(filters: TableFilters) {
const { pageIndex = 0, pageSize = 25 } = filters.pagination ?? {};
return {
offset: pageIndex * pageSize,
limit: pageSize,
// sorting: [{ id, desc }] -> "name:asc,createdAt:desc"
sort: (filters.sorting ?? []).map((s) => `${s.id}:${s.desc ? 'desc' : 'asc'}`).join(','),
search: filters.globalFilter || undefined,
// advanced rules -> your own WHERE representation
where: {
op: filters.columnFilter?.logic ?? 'AND', // combine with AND / OR
rules: (filters.columnFilter?.filters ?? []).map((r) => ({
field: r.columnId,
operator: r.operator, // map to your DB operator
value: r.value,
})),
},
};
}

Keep the response to the current page. In server mode the grid renders exactly the rows you return (no client paging). Return pageSize rows and the true total.


Approach B — controlled data (you fetch it yourself)

You are not required to use onFetchData. If you already fetch data with React Query, SWR, Redux, etc., just feed the grid your rows via data + rowCount, mark the concerns as server-managed, and listen to onDataStateChange to know when to refetch.

function UsersTable() {
const [params, setParams] = useState({ pageIndex: 0, pageSize: 25, sorting: [], columnFilter: undefined, globalFilter: '' });

// YOUR fetch — React Query, SWR, anything. The grid never calls it.
const { data, isLoading } = useQuery(['users', params], () => fetchUsers(buildQuery(params)));

return (
<DataTable
columns={columns}
dataMode="server"
data={data?.rows ?? []} // <- you set the rows directly
rowCount={data?.total ?? 0} // <- and the total
loading={isLoading}
enablePagination
enableSorting
enableGlobalFilter
enableColumnFilter
// Fires whenever sort / page / filter / column state changes -> refetch with the new state:
onDataStateChange={(state) => setParams((p) => ({ ...p, ...state }))}
/>
);
}

onDataStateChange(state) gives you the full snapshot — sorting, pagination, globalFilter, columnFilter, plus column visibility / sizing / order / pinning — so you refetch with the exact same fields documented above. The grid renders your data as-is and keeps the pager honest via rowCount.

Use loading to show the grid's skeletons while your own request is in flight.


Granular server modes

You don't have to move everything server-side at once. Opt in per concern — the rest stays client-side (and the grid keeps sorting/filtering/paging those locally):

<DataTable
sortingMode="server"
filterMode="server"
paginationMode="server"
/* anything left as 'client' is processed in the browser */
/>

dataMode="server" is shorthand for all three. This works with either approach above — onFetchData fires (or onDataStateChange notifies) only for the concerns you delegated.

The onFetchData contract, at a glance

onFetchData: (filters: TableFilters, meta: DataFetchMeta) => Promise<{ data: T[]; total: number }>
FieldDescription
filters.pagination{ pageIndex, pageSize }
filters.sorting[{ id, desc }]
filters.globalFilterthe search string
filters.columnFilter{ filters: Rule[], logic: 'AND' | 'OR' } — applied advanced rules
meta.reasonwhy it fired ('initial', 'state-change', 'refresh', 'reload', 'reset')

The grid calls onFetchData on mount (unless initialLoadData={false}) and on every relevant change. onFetchStateChange(filters, meta) fires just before each fetch — handy for syncing the URL or logging.

Exporting server data

Server data has its own export modes, so the browser never has to hold or build a huge file:

  • client (default) reuses onFetchData to page through everything, then formats + writes on the client.
  • server-data streams rows from onExportStream (the client still formats).
  • server-file / server-async hand the whole ExportRequest (columns + selection + filters) to your backend, which builds the file and returns { blob }, { fileUrl }, or a { jobId } to poll.

For 1M+ rows, prefer a server mode with CSV. See Export for the full contract.

Imperative data API

Refresh and mutate through apiRef.current.data:

apiRef.current.data.refresh(); // re-run the fetch (or fire onDataStateChange) with current state
apiRef.current.data.reload(); // reload, keeping state
apiRef.current.data.updateRow(id, patch); // optimistic local update
apiRef.current.data.deleteSelectedRows();