Apply typed row-model commands and separate UI-state commands.
Data and derivation commands belong to rowModel. Focus, selection, editing, viewport, and visual layout belong to grid.
Rows and atomic transactions
Clicking the button below runs one applyTransaction call that adds a task, marks another done, and tries to remove an id that was never there — all as a single revision:
Atomic transactions
One applyTransaction call adds a row, updates a row, and attempts to remove one that never existed — all landing as a single revision, with the unknown removal reported as a non-fatal issue.
Updates use the exact { id, changes } shape. One call publishes one atomic revision and returns counts, issues, and previous/current revisions — the readout above prints exactly those fields after each click. Unknown update/removal IDs are reported as non-fatal issues rather than thrown, which is what turns the example's always-missing task-ghost removal into an unknown-remove-id issue instead of an error.
In React's declarative mode, update the rows prop instead. Use an explicit row model for imperative or high-frequency producers.
Query
Filters, ordered sorting, and grouping are one typed query value:
Typing below cancels whichever distinctValues request is still in flight and starts a new one, so a slow early keystroke can never overwrite a faster later one:
Distinct-value search
A team search box calls rowModel.distinctValues on every keystroke, cancelling the previous request so a slow early result can never overwrite a faster later one.
"use client";import { useCallback, useEffect, useRef, useState } from "react";import { createLocalRowModel, type PretableDistinctValueQuery,} from "@pretable/core";import { useDisposeOnUnmount } from "@pretable/react";import { columns } from "./columns";import { contacts } from "./data";interface TeamOption { readonly value: string; readonly count: number;}export function DistinctValuesDemo() { const [rowModel] = useState(() => createLocalRowModel({ columns, rows: contacts }), ); useDisposeOnUnmount(rowModel); const [search, setSearch] = useState(""); const [options, setOptions] = useState<readonly TeamOption[]>([]); const [totalDistinct, setTotalDistinct] = useState(0); // Starts true: the mount effect below fires the first request without // itself calling setState synchronously, so "pending" has to already be // true rather than being set by that effect. const [pending, setPending] = useState(true); // Cancelling the in-flight request when a new one starts — rather than // letting a slow earlier keystroke resolve after a faster later one — is // what "asynchronous and cancellable" buys you here. const activeRequest = useRef<PretableDistinctValueQuery<string> | null>(null); const startSearch = useCallback( (value: string) => { activeRequest.current?.cancel(); const request = rowModel.distinctValues("team", { search: value, limit: 8, }); activeRequest.current = request; request.finished .then((result) => { if (activeRequest.current !== request) return; // superseded setOptions( result.values.map((v) => ({ value: v.value, count: v.count })), ); setTotalDistinct(result.totalDistinct); setPending(false); }) .catch(() => { // A cancelled request rejects `finished`. That's expected every // time a keystroke supersedes the previous request, so there is // nothing to surface here. }); }, [rowModel], ); useEffect(() => { startSearch(""); // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-once; later searches come from onChange }, []); return ( <div> <label style={{ display: "block", marginBottom: 8, fontSize: 13 }}> Search teams{" "} <input aria-label="Search teams" value={search} onChange={(e) => { setSearch(e.target.value); setPending(true); startSearch(e.target.value); }} /> </label> <p role="status" style={{ fontSize: 13 }}> {pending ? "Searching…" : `${totalDistinct} matching team${totalDistinct === 1 ? "" : "s"}`} </p> <ul> {options.map((opt) => ( <li key={opt.value}> {opt.value} ({opt.count}) </li> ))} </ul> </div> );}
ts
const request = rowModel.distinctValues("team", { search: "pay", limit: 50 });const result = await request.finished;request.cancel(); // rejects `finished` with a cancellation error
Distinct-value lookup is asynchronous and cancellable — the example's activeRequest.current?.cancel() on every keystroke is that cancellation in practice, and its .catch() is there because a cancelled request rejects finished, not because failure is expected.