Controlled and uncontrolled state

Letting DataGrid keep its own sort, filter, grouping, paging, selection and column layout, or holding any of them in your component and passing it back in.

Last updated November 2, 2026

Every piece of state a user can change — the sort, the filter, the grouping, which groups are collapsed, the page, the selection, the columns' widths, order and visibility — is either kept by the grid or held by you, one slice at a time, the way a React <input> takes either defaultValue or value.

Uncontrolled: the grid keeps it#

Give a starting value with the default* prop, or nothing at all, and the grid keeps the slice itself. Its on*Change callback still reports every change, for persisting or reacting to it.

<DataGrid
  columns={columns}
  dataSource={rows}
  sortableColumns
  defaultColumnSort={[{ columnId: "Amount", direction: "desc" }]}
  onColumnSortChange={({ sort }) => save(sort)}
/>

Controlled: you keep it#

Pass the slice itself and the grid shows exactly that. A header click no longer changes the sort — it asks for a change through onColumnSortChange, and the grid follows once you pass the new value back:

const [sort, setSort] = useState<ColumnSortState>([]);

<DataGrid
  columns={columns}
  dataSource={rows}
  sortableColumns
  columnSort={sort}
  onColumnSortChange={(event) => {
    setSort(event.sort);
  }}
/>;

That makes it yours to adjust, limit or ignore — keep at most two sort keys, refuse to sort a column while a request is in flight, or drive the sort from the URL. It also lets two grids, or a grid and a toolbar, share one value.

StateControlled propUncontrolled defaultChange callback
SortcolumnSortdefaultColumnSortonColumnSortChange
FilterfilterdefaultFilteronFilterChange
Column widthscolumnSizingdefaultColumnSizingonColumnResize
Column ordercolumnOrderdefaultColumnOrderonColumnOrderChange
Shown and hidden columnscolumnVisibilitydefaultColumnVisibilityonColumnVisibilityChange
Group-by stackgroupBydefaultGroupByonGroupByChange
Collapsed groupsgroupExpansiondefaultGroupExpansiononGroupExpansionChange
Page and sizepaginationdefaultPaginationonPaginationChange
Selected rowsrowSelectiondefaultRowSelectiononRowSelectionChange (and the other row callbacks)
Selected columnscolumnSelectiondefaultColumnSelectiononColumnSelectionChange (and the other column callbacks)
Selected cellcellSelectiondefaultCellSelectiononCellSelectionChange (and the other cell callbacks)

Each slice is independent: control the sort and leave paging to the grid, or the other way round. Passing undefined is the same as leaving the prop out, so a slice can start uncontrolled and be taken over later. Passing both a controlled prop and its default logs a console.error once; the controlled value wins.

Keep a controlled value stable — the same array or object until it changes, as React state is. A new array on every render reads as a new sort each time, and sends an uncontrolled page back to the first page each time too.

The page goes back to the first page#

When the filter, the sort or the grouping changes, the grid sends its own page back to the first one, so nobody is left on page 7 of a result that now has two. That reset is a consequence of the change, not a page change of its own, so it does not call onPaginationChange.

A controlled pagination is never changed behind your back. Reset it alongside the change if you want the same behaviour; until you do, the grid shows the last page that exists rather than an empty one.

<DataGrid
  pagination={pagination}
  onPaginationChange={(event) => {
    setPagination(event.pagination);
  }}
  onColumnSortChange={(event) => {
    setSort(event.sort);
    setPagination((current) => ({ ...current, pageIndex: 0 }));
  }}
/>

Saving and restoring a layout#

The ref reads every slice at once with getState() and puts a saved one back with restoreState() — a user's column layout, sort and filter kept across visits, or shared through a URL. serializeGridState makes the state safe for JSON first:

import { serializeGridState, type DataGridApi } from "@gridkitjs/react";

const gridRef = useRef<DataGridApi<Row>>(null);

function save() {
  const state = gridRef.current?.getState();
  if (state !== undefined) {
    localStorage.setItem(
      "orders-grid",
      JSON.stringify(serializeGridState(state)),
    );
  }
}

function restore() {
  const saved = localStorage.getItem("orders-grid");
  if (saved !== null) {
    gridRef.current?.restoreState(JSON.parse(saved));
  }
}

restoreState takes the whole state or any part of it — { columnSizing, columnOrder, columnVisibility } restores just the column layout. Each slice that changes fires its own callback, as its setter would, and the grid re-renders once. A restored page is kept even when the same restore changes the sort or the filter, which would otherwise send it back to the first page.

Everything in the state is plain data except the filter. serializeGridState writes a Date as its ISO string, which restoreState turns back into a Date. A predicate entry is a function and cannot be saved: it is left out, with a console.error naming its column. Write a filter you want saved with operator entries instead — a range, a list of values or a blank check needs no predicate.

For a slice you hold with a controlled prop, keep it in your own state as you already do — restoreState only asks for it, through the slice's callback.

See also#

Edit this page on GitHub