gridcn1.2.2
RecipesData fetching

Server round-trip

Send each edit to an API, then roll back, refetch, or show the server error.

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}
      onDataChange={(next, change) => handleDataChange(next, change, setData)}
    />
  );
}

Translate operations into API calls

change.ops is an array of DataOp<Order>. Each operation names its own shape, so a switch on op.type maps to one API call per operation. The move operations of a batch go in one request:

function opsToRequests(ops: DataOp<Order>[]) {
  const requests = ops.flatMap((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)];
      case "move":
        return [];
    }
  });
  const moves = ops.filter((op) => op.type === "move");
  if (moves.length > 0) requests.push(api.orders.reorder(moves.map(({ rowId, to }) => ({ rowId, to }))));
  return requests;
}

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]));
}

Each move's to is the row's final index after the whole batch. The server removes all moved rows, then inserts them in ascending to order, as applyChange does. Moves applied one at a time end in the wrong order.

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 current 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 (on the grid's ref handle) 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,
  gridRef: RefObject<DataGridApi<Order> | null>,
) {
  setData(next as Order[]);

  Promise.all(opsToRequests(change.ops)).catch((error) => {
    gridRef.current?.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 does not roll back the value on its own. Pair it with Strategy A's rollback, or data-grid-history's undo(), when leaving the rejected value showing is the wrong choice.

On this page