Recipes
Server round-trips, TanStack Query, react-hook-form dialogs, and more.
Server round-trip
Builds on Events & state's onDataChange section,
which shows the exact DataOp/DataChange shape referenced below.
Initial fetch
Go controlled (see Quick start). Fetch once, then hold
data in state:
"use client";
function OrdersGrid() {
const [data, setData] = useState<Order[]>([]);
useEffect(() => {
let cancelled = false;
api.orders.list().then((rows) => {
if (!cancelled) setData(rows);
});
return () => {
cancelled = true;
};
}, []);
return (
<DataGrid
data={data}
columns={columns}
getRowId={(row) => row.id}
onDataChange={(next, change) => handleDataChange(next, change, setData)}
/>
);
}Translate ops into API calls
change.ops is an array of DataOp<Order>. Each op names its own shape, so a switch on
op.type maps directly to one API call per op:
function opsToRequests(ops: DataOp<Order>[]) {
return ops.map((op) => {
switch (op.type) {
case "update":
// op.cells is present for a cell edit/paste/fill; absent for a whole-row update.
// Send a patch keyed by rowId, not an index — indices don't survive sort or filter.
return api.orders.patch(op.rowId, op.cells ? cellsToPatch(op.cells) : op.row);
case "insert":
return api.orders.create(op.row);
case "delete":
return api.orders.remove(op.rowId);
}
});
}
type UpdateOp<TData> = Extract<DataOp<TData>, { type: "update" }>;
function cellsToPatch(cells: NonNullable<UpdateOp<Order>["cells"]>) {
return Object.fromEntries(cells.map((cell) => [cell.columnId, cell.value]));
}Strategy A: optimistic, roll back on failure
Apply the edit locally right away (the grid is controlled, so setData(next) is what makes it
visible), fire the API calls, and revert to the previous array if any of them reject:
function handleDataChange(
next: readonly Order[],
change: DataChange<Order>,
setData: (rows: Order[]) => void,
) {
const prev = dataRef.current; // keep a ref in sync with `data` for the rollback snapshot
setData(next as Order[]);
Promise.all(opsToRequests(change.ops)).catch((error) => {
setData(prev);
toast.error("Could not save your change. It has been reverted.");
reportError(error);
});
}Keep a ref, not just the state variable
The rollback needs the array from BEFORE this change, not the latest one. A plain closure over
data in a useCallback captures a stale snapshot across renders. Mirror data into a ref
on every render, and roll back to ref.current as it stood at the start of this handler.
Strategy B: refetch-on-settle
Do not apply next at all. Send the request first, then replace data with a fresh fetch once
the server confirms the write. The grid shows the old value until the round-trip completes:
function handleDataChange(_next: readonly Order[], change: DataChange<Order>, setData: (rows: Order[]) => void) {
Promise.all(opsToRequests(change.ops))
.then(() => api.orders.list())
.then(setData)
.catch((error) => {
toast.error("Could not save your change.");
reportError(error);
});
}Optimistic vs refetch-on-settle
Optimistic feels instant but needs a correct rollback path. Refetch-on-settle is simpler and always shows server-confirmed data, at the cost of a visible delay per edit. Pick optimistic for frequent, low-risk edits (a status toggle); pick refetch-on-settle for edits a server can reject or recompute (a price that triggers server-side discounts).
The failure path
A rejected write is common: a 422 with field-level errors is the typical shape from a REST or
GraphQL backend. actions.setCellErrors paints the rejection on the exact cell it names, so the
user sees which value is wrong and can fix it in place:
function handleDataChange(next: readonly Order[], change: DataChange<Order>, setData: (rows: Order[]) => void) {
setData(next as Order[]);
Promise.all(opsToRequests(change.ops)).catch((error) => {
actions.setCellErrors(parseFieldErrors(error, change.ops)); // [{ rowId, columnId, message }]
});
}parseFieldErrors is your own mapping from the server's error shape to { rowId, columnId, message } entries, matched against change.ops to recover each op's rowId. The cell gets the
same ring, tint, and aria-invalid treatment as a rejected validate call, and the message shows
when the user opens the cell to edit it. The error clears the moment the user commits a fixed
value, through any write path. See
Editing & cell types: Server errors for the full
contract, including clearCellErrors and the read hook.
Toast and revert, or history undo, still apply
setCellErrors marks the cell. It does not roll back the value on its own. Pair it with
Strategy A's rollback, or with
data-grid-history's undo(), when the wrong choice is to leave the
rejected value showing in the grid at all.
TanStack Query wiring
Grid-side parts are the contract; TanStack Query parts are illustrative
gridcn does not install @tanstack/react-query — it stays a zero-added-dependency library.
The snippet below is written against TanStack Query's stable public API
(useQuery/useMutation/onMutate/onError), the same illustrative-snippet convention as the
Standard Schema example in
Editing & cell types. Add @tanstack/react-query to your
own app first if you want this pattern. See also
Lazy loading's React Query aside
for the same rule applied to data-grid-lazy.
useQuery fetches the rows, and one useMutation handles every op from onDataChange, keyed
off the same queryKey so its cache and the grid stay in sync:
"use client";
function OrdersGrid() {
const queryClient = useQueryClient();
const ordersQuery = useQuery({ queryKey: ["orders"], queryFn: api.orders.list });
const mutation = useMutation({
mutationFn: (change: DataChange<Order>) => Promise.all(opsToRequests(change.ops)),
onMutate: async (change) => {
await queryClient.cancelQueries({ queryKey: ["orders"] });
const previous = queryClient.getQueryData<Order[]>(["orders"]);
queryClient.setQueryData<Order[]>(["orders"], (rows) => applyOpsLocally(rows ?? [], change.ops));
return { previous };
},
onError: (_error, _change, context) => {
if (context?.previous) queryClient.setQueryData(["orders"], context.previous);
toast.error("Could not save your change. It has been reverted.");
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
});
return (
<DataGrid
data={ordersQuery.data ?? []}
columns={columns}
getRowId={(row) => row.id}
onDataChange={(_next, change) => mutation.mutate(change)}
/>
);
}applyOpsLocally is your own small reducer over DataOp[] (update by rowId, splice an insert
at index, filter out a delete). onMutate's optimistic cache write is what makes the edit look
instant; onError's context.previous rollback is what undoes it if the mutation rejects. This
is the same optimistic-vs-rollback shape as Strategy A
above, expressed through TanStack Query's cache instead of a plain useState/ref pair.
For a socket or poll that pushes new values many times per second, write them with updateCells
instead of a new data array. See Streaming updates.
react-hook-form dialog editing
The escape hatch for multi-field edits
Inline cell editors are single-value, single-column. Reach for a dialog form when an edit needs
MULTIPLE fields at once or a layout the grid's inline editors cannot express. For a single
value in one column, the built-in cell types (see
Editing & cell types) are simpler and stay in place — do not reach
for a dialog by default. For cross-field RULES on inline paths (paste, fill, bulk update), use
validateRow — those paths never
open a dialog.
Illustrative — react-hook-form is not installed
gridcn does not install react-hook-form. The snippet is written against its stable public
API (useForm/register/handleSubmit), the same illustrative-snippet convention as the
TanStack Query recipe above. Add react-hook-form to your own app first if you want this
pattern.
A row action (for example, a context menu item or a button column) opens a shadcn Dialog. The
form seeds from the clicked row, and submit builds one update op through the grid's normal
controlled onDataChange path, so undo, history, and the server round-trip above all see it the
same way as an inline edit:
"use client";
function EditOrderDialog({
row,
open,
onOpenChange,
onSave,
}: {
row: Order;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (row: Order) => void;
}) {
const form = useForm<Order>({ defaultValues: row });
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form
onSubmit={form.handleSubmit((values) => {
onSave(values);
onOpenChange(false);
})}
>
<Input {...form.register("customerName")} />
<Input type="number" {...form.register("price", { valueAsNumber: true })} />
<Input type="number" {...form.register("discount", { valueAsNumber: true })} />
<DialogFooter>
<Button type="submit">Save</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}onSave writes the edited row back through the grid's own controlled state, the same
setData/onDataChange path as any other edit:
function saveOrder(edited: Order) {
setData((rows) => rows.map((row) => (row.id === edited.id ? edited : row)));
}This bypasses per-column validate
A dialog form's own validation (RHF resolvers, or plain field checks) runs instead of each
column's validate, not alongside it. Re-run the same rules in the form if a column has a
validate you still need enforced here.
Auth-gated cells
Derive readOnly and column visibility from your own session or auth hook, per column, per row,
or both. readOnly accepts a function of the row (see
Editing & cell types), so a role check reads naturally:
function useOrderColumns(): ColumnDef<Order>[] {
const { role } = useSession();
return defineColumns<Order>()([
{ id: "customerName", header: "Customer", accessorKey: "customerName", type: "text" },
{
id: "price",
header: "Price",
accessorKey: "price",
type: "number",
readOnly: (row) => role !== "admin" || row.locked,
},
{
id: "internalNote",
header: "Internal note",
accessorKey: "internalNote",
type: "text",
hidden: role === "guest",
},
] as const);
}price composes two conditions: a role gate (only admin can edit it) and a per-row gate
(a locked order is read-only for everyone). internalNote is hidden outright for guest,
computed once per render from the same session hook, not per row.
Server-side sort/filter
sortState, filterState, and searchText each have a controlled prop pair on
DataGridProvider (and DataGrid): sortState/onSortChange, filterState/onFilterChange,
and searchText/onSearchTextChange (see the three shapes
for how the prop, the callback, and the uncontrolled fallback interact). The callback fires on every
user gesture, and the prop you feed back in is what the grid displays:
"use client";
function ServerSyncedGrid() {
const [data, setData] = useState<Row[]>([]);
const [sortState, setSortState] = useState<SortSpec[]>([]);
const [filterState, setFilterState] = useState<FilterSpec[]>([]);
const grid = useDataGridState(data, { getRowId: (r) => r.id });
useEffect(() => {
fetchRows({ sortState, filterState }).then(setData);
}, [sortState, filterState]);
return (
<DataGridProvider
{...grid}
columns={columns}
sortState={sortState}
onSortChange={setSortState}
filterState={filterState}
onFilterChange={setFilterState}
>
<DataGridRoot>
<DataGridHeader />
<DataGridBody />
</DataGridRoot>
</DataGridProvider>
);
}Add request cancellation for production use
A fast second sort click before the first fetch resolves can land results out of order. The grid's contract ends at "the prop you feed back in is what is displayed", not at de-duplicating your own fetches.
A non-empty `sortState` is still applied to `data`
The grid sorts data by whatever sortState holds. That is harmless above, because the server
returns the whole result set and re-applying the same order changes nothing. It is not harmless
when data holds only part of the result set, as under
lazy loading: sorting a partial array re-orders the
fetched rows among themselves and hides the true server order. sortable: false does not change
this — it only removes the header's click and indicator, never the comparator.
Client-side sort/filter needs none of this
This is the common case, which works well up to the 100k-row comfort target. Omit sortState,
filterState, onSortChange, and onFilterChange, and let the grid own the state
uncontrolled, as the quick-start of useDataGridState already does.
Custom cell type
Looking for the full guide?
This is a short, minimal sketch. Build your own cell type has the
complete contract walk-through, a keypoint checklist (performance caching, SSR-safe formatting,
editor commit semantics, the cellTypes-replaces-not-merges gotcha, popover outside-click
handling), a runnable worked currency example, and a testing pattern.
A cell type is { Cell, Editor, toText, fromText, clearValue, isEmpty, compare?, align? }. Here
is a minimal currency type modeled on the built-in number type:
import type { CellEditorProps, CellRenderProps, CellType } from "@/components/data-grid/types";
import { useCommitGuard } from "@/components/data-grid/data-grid";
import { Input } from "@/components/ui/input";
type CurrencyOptions = { currency: string; locale?: string };
// Keyed on serialized options, not object identity — a column's `options` literal is re-created
// every render. The locale is pinned (not the runtime's), so server and client agree.
const currencyFormatterCache = new Map<string, Intl.NumberFormat>();
function getCurrencyFormatter(locale: string, currency: string): Intl.NumberFormat {
const key = `${locale}|${currency}`;
let formatter = currencyFormatterCache.get(key);
if (!formatter) {
formatter = new Intl.NumberFormat(locale, { style: "currency", currency });
currencyFormatterCache.set(key, formatter);
}
return formatter;
}
function CurrencyCell({ value, column }: CellRenderProps<unknown, number | null>) {
const options = column.options as CurrencyOptions | undefined;
if (value == null) return null;
return (
<span className="tabular-nums">
{getCurrencyFormatter(options?.locale ?? "en-US", options?.currency ?? "USD").format(value)}
</span>
);
}
function CurrencyEditor({ value, initialText, onChange, commit, cancel }: CellEditorProps<unknown, number | null>) {
const [text, setText] = useState(initialText ?? String(value ?? ""));
// One-shot latch: Enter commits, then the blur that follows must not commit a second time.
const committed = useCommitGuard();
const commitText = (movement?: { dx: number; dy: number }) => {
if (!committed.tryCommit()) return;
onChange(Number(text) || null);
commit(movement);
};
return (
<Input
autoFocus
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitText({ dx: 0, dy: 1 });
if (e.key === "Escape") cancel();
}}
onBlur={() => commitText({ dx: 0, dy: 0 })}
/>
);
}
export const currencyCellType: CellType<unknown, number | null, CurrencyOptions> = {
Cell: CurrencyCell,
Editor: CurrencyEditor,
toText: (value) => (value == null ? "" : String(value)),
fromText: (text) => (text.trim() === "" ? null : Number(text) || null),
clearValue: () => null,
isEmpty: (value) => value == null,
compare: (a, b) => (a ?? 0) - (b ?? 0),
align: "right",
};Register it, and optionally give it compile-time inference through defineColumns:
declare module "@/components/data-grid/types" {
interface GridCellTypes {
currency: { value: number | null; options: CurrencyOptions };
}
}
import { cellTypes } from "@/components/data-grid/data-grid";
// spread the built-ins in — `cellTypes` REPLACES the registry, it does not merge
<DataGridProvider cellTypes={{ ...cellTypes, currency: currencyCellType }} {...grid} columns={columns}>const columns = defineColumns<Product>()([
{ id: "price", header: "Price", accessorKey: "price", type: "currency", options: { currency: "EUR" } },
] as const);See Build your own cell type for the full contract walk-through, the keypoint checklist, and a complete runnable example. See Editing & cell types for how editing activation and commit work across the grid, and API reference for every field's exact type.
Multiple grids on one page
Each <DataGridProvider> mounts its own Zustand store instance (createStore in a useState
factory). There is no module-level singleton, so multiple grids on one page just work with no
configuration:
<div className="grid grid-cols-2 gap-4">
<DataGridProvider {...gridA} columns={columnsA}>
<DataGridRoot><DataGridHeader /><DataGridBody /></DataGridRoot>
</DataGridProvider>
<DataGridProvider {...gridB} columns={columnsB}>
<DataGridRoot><DataGridHeader /><DataGridBody /></DataGridRoot>
</DataGridProvider>
</div>data-grid-url-state needs a distinct prefix per grid
Without a distinct prefix, two grids syncing to the URL collide on the same sort, filter,
join, and q keys. See URL state.