Controller

createGridController: a grid's state, view and actions, without a renderer.

Last updated November 2, 2026

createGridController is a whole grid minus the drawing. It holds the grid's state, derives the view from it with resolveGridView, and carries every action a user can take — sorting, filtering, grouping, expanding, paging, resizing, reordering, hiding columns, selecting. @gridkitjs/react's DataGrid creates one per grid, hands it its props on every render, draws its view and calls its actions from event handlers; a renderer for another framework does the same, so none of them decides anything the user can see.

import { createGridController } from "@gridkitjs/core";

const controller = createGridController<Row>({
  dataSource: rows,
  getRowId: (row) => String(row.Id),
  columns,
  paginated: true,
  onColumnSortChange: (event) => console.log(event.sort),
});

controller.subscribe(() => draw(controller.getView()));

controller.toggleColumnSort("Amount"); // ascending; draw() runs, then the callback above
controller.nextPage();
controller.getState().pagination; // { pageIndex: 1, pageSize: 25 }

Options#

createGridController takes a GridControllerOptions<Row, Node>: every GridOptions field and selection callback (onRowSelect, onRowSelectionChange and the rest), plus columns (inferred from dataSource when omitted or empty, as defineColumnsFromRows does) and viewportWidth, the width resizeMode: "fit" fills — null or absent where there is none, such as an export in Node — and onSelectionDiff, called after each row or column selection change with the ids it added and removed, including those with no row on screen, for a renderer's own feedback such as a live-region count.

setOptions(options) replaces them all, as a renderer does with its current props on each update. Calling it again with equal options changes nothing, so it is safe to call on every render. It does not call subscribers: the renderer calling it is already drawing the result.

Controlled and uncontrolled state#

Each slice of GridState is controlled or not on its own, the way a form input is. Leave the slice's option out and the controller keeps the slice itself, starting from its default. Give the option and the controller shows exactly that value; an action then only asks for a change, through the slice's callback, and the change takes effect when the next setOptions carries it.

SliceControlled optionDefaultCallback
columnSizingcolumnSizingdefaultColumnSizingonColumnResize
columnOrdercolumnOrderdefaultColumnOrderonColumnOrderChange
columnVisibilitycolumnVisibilitydefaultColumnVisibilityonColumnVisibilityChange
filterfilterdefaultFilteronFilterChange
rowSelectionrowSelectiondefaultRowSelectiononRowSelectionChange and the other row callbacks
columnSelectioncolumnSelectiondefaultColumnSelectiononColumnSelectionChange and the other column callbacks
cellSelectioncellSelectiondefaultCellSelectiononCellSelectionChange, onCellSelect, onCellDeselect
columnSortcolumnSortdefaultColumnSortonColumnSortChange
groupBygroupBydefaultGroupByonGroupByChange
groupExpansiongroupExpansiondefaultGroupExpansiononGroupExpansionChange
paginationpaginationdefaultPaginationonPaginationChange

Giving both the controlled option and its default reports a console.error once; the controlled value wins.

A change to the filter, the sort or the grouping sends an uncontrolled pagination back to the first page, so a user is never left on a page the change emptied. That reset reports nothing of its own — the change that caused it already did. A controlled pagination is the caller's to reset; until it is, the view shows the last page that exists.

Reading#

MethodTypeDescription
getState()GridState<Row>Every slice as the grid applies it — the controlled value where one is given. The same object until a slice changes.
getView()GridView<Row, Node>What the user is looking at. The same object until something in it changes.
getOptions()GridControllerOptions<Row, Node>The options last given.
subscribe(fn)() => voidCalls fn after every action that changed something, synchronously. Returns the unsubscribe function.

Because both snapshots keep their identity until something changes, either can serve as the snapshot of a subscription that compares by reference — React's useSyncExternalStore, an Angular signal, a Svelte store.

Actions#

Every action works out the next value, stops if it is the value the slice already holds, keeps it unless the slice is controlled, fires the slice's callback with the same payload a user's interaction fires, then calls the subscribers.

ActionCallbackDescription
resizeColumn(columnId, width, phase)onColumnResizeSets one column's width, as a drag does: "move" while it lasts, "end" once. Never skipped for an unchanged width.
cancelColumnResize(columnId, startSizing, startWidth)onColumnResizePuts a column back as it was when a resize began, keeping other columns' changes, reported as the "end" at startWidth.
setColumnSizing(sizing)onColumnResizeReplaces the widths, reported as the "end" of a resize of the first column that changed.
moveColumn(columnId, beforeId)onColumnOrderChangeMoves a column in front of another, or to the end for null. Hidden columns keep their place.
setColumnOrder(order)onColumnOrderChangeReplaces the order, naming the first column whose position changed.
showColumns(ids) / hideColumns(ids)onColumnVisibilityChangeOne report per call, naming every column it touched in display order.
setColumnVisibility(visibility)onColumnVisibilityChangeReplaces the visibility state.
setFilter(filter)onFilterChangeReplaces the filter.
selectRow(rowId, intent)row callbacksOne click or key press — "replace", "toggle" or "range" from the last non-range selection, across the data rows on the current page.
selectRows(rowIds, intent?)row callbacksSelects rows as a user could, within selectable.rows: "replace" (only the last id under "single") or "toggle".
selectAllRows()row callbacksEvery data row on the current page, under "multiple".
setRowSelection(selection)row callbacksReplaces the selected ids as given.
selectColumn(columnId, intent) / setColumnSelection(selection)column callbacksThe same, for the columns as rendered.
selectCell(cell, intent) / setCellSelection(selection)cell callbacksSelects a cell, or lets go of it on "toggle"; or replaces it, null for none.
clearSelection()all threeLets go of every row, column and cell selection, as Escape does.
toggleColumnSort(columnId, { stack? })onColumnSortChangeCycles a column ascending → descending → off. stack adds it to the sort instead of replacing it.
setColumnSort(sort)onColumnSortChangeReplaces the sort, naming the first column whose place or direction changed.
toggleGroupBy(columnId)onGroupByChangeAdds a column to the end of the group-by stack, or removes it.
moveGroupBy(columnId, beforeColumnId)onGroupByChangePuts a column in front of another in the stack, or at the end for null.
setGroupBy(groupBy)onGroupByChangeReplaces the stack, naming the first level that changed.
toggleGroupExpansion(groupId)onGroupExpansionChangeCollapses an expanded group, or expands a collapsed one.
expandAllGroups() / collapseAllGroups()onGroupExpansionChangeEvery group at once, reported with groupId: null.
setGroupExpansion(expansion)onGroupExpansionChangeReplaces the collapsed groups, reported with groupId: null.
goToPage(pageIndex)onPaginationChangeMoves to a page, clamped into range.
nextPage() / previousPage()onPaginationChangeOne page on or back.
setPageSize(pageSize)onPaginationChangeChanges the page size and goes back to the first page.
setPagination(pagination)onPaginationChangeReplaces the page and page size as given.

Every set* action also stops for a value equal to the current one, so setting what is already there reports nothing. The page actions do the same: nextPage() on the last page, previousPage() on the first, goToPage() to the page already shown (or one that clamps to it) and setPageSize() with the current size on the first page leave the pagination as it is, so they neither fire onPaginationChange nor call subscribers.

Saving and restoring state#

restoreState(saved) puts back state read earlier with getState(). Every slice it is given is set as its own set* action would set it — each one that changes fires its callback, and a controlled one only asks — while slices left out keep their value. Subscribers are called once, after every slice is in place, and a restored pagination is kept even when the same call changed the sort, filter or grouping.

getState() holds plain data except in the filter, where a Date value does not survive JSON and a predicate entry is a function. serializeGridState(state) returns state that does: a Date becomes its ISO string, which restoreState turns back into a Date — in a value entry always, in an operator entry when its column is a date, date-time or time column — and a predicate entry is left out with a console.error naming its column — along with any group it leaves empty.

import { serializeGridState } from "@gridkitjs/core";

localStorage.setItem(
  "orders-grid",
  JSON.stringify(serializeGridState(controller.getState())),
);

const stored = localStorage.getItem("orders-grid");
if (stored !== null) {
  controller.restoreState(JSON.parse(stored));
}

A saved state may be partial — { columnSizing, columnOrder, columnVisibility } restores a column layout and leaves the sort, filter and selection alone.

Edit this page on GitHub