Transactions
One call to change rows and cells together, atomically, synchronously or batched per frame.
api.transaction() is the one way to write data. A transaction can add, update and remove rows, write cells,
and change grid state (columns, sort, filter, pins), and it commits all of it as one change: one undo entry, one
repaint of what changed. Wit Grid keeps sort, filter, grouping, selection and scroll position through it, and
only reworks the rows and cells the transaction touches. Row changes are available on the client-side row model.
Choosing a write
| Method | Use it for |
|---|---|
setRows(rows) | Handing the grid your latest rows array. Only the rows that differ update. |
transaction(tx) | Adding, updating or removing rows and writing cells, applied immediately. |
transaction(tx, { async: true }) | High-frequency changes, applied together before the next frame. |
prepare(write) / commit(p) | Showing what cell writes would change, then committing them if still fresh. |
transaction
A transaction lists rows to add, update and remove, and cells to write. Rows are matched to existing rows by
getRowId.
import type { GridApi } from '@witbybit/wit-grid-react';
type Trade = { id: string; symbol: string; price: number };
export function applyFills(api: GridApi<Trade>) {
const result = api.transaction({
rows: {
add: [{ id: 't-104', symbol: 'MSFT', price: 431.2 }],
update: [{ id: 't-101', symbol: 'AAPL', price: 227.9 }],
remove: [{ id: 't-099', symbol: 'TSLA', price: 0 }],
},
});
// result.status is 'applied', 'noop' or 'rejected'; result.rows lists the row nodes that were
// added, updated and removed (empty lists when there were none).
return result.rows.add[0];
}| Field | Effect |
|---|---|
rows.add | Rows to insert. They are appended in source order unless rows.addIndex is set. |
rows.addIndex | Source position to insert the added rows at. |
rows.update | Replacement row objects. Each replaces the row with the same id; rows that do not exist are ignored. |
rows.remove | Rows to remove, matched by id. Only the id needs to be right. |
cells | Cell writes, { rowId, colField, value }, through each column's value setter, formulas and validation. |
source | Where the cell writes come from: 'api' (default), 'paste' or 'fill'. Picks the validation policy. |
policy | Cell writes that cannot apply: 'atomic' (default) rejects them all; 'bestEffort' applies the others and lists the rest in cells.rejected. |
asUser | Check the user's capabilities for each cell, as an edit or paste does. The api itself is not limited by them. |
order | The rows' order by id, applied after the row and cell writes in the same commit. |
The result is the write status plus what was touched:
status:'applied','noop'when nothing changed, or why nothing was written:'rejected','capabilityDenied','validationFailed'(with itsissues) or'failed', each with areason.rows: the public row nodes that were added, updated and removed.cells:committed(the writes that landed) andrejected(each with thereason).
A transaction is atomic and one undo step: api.undo() reverts all of it, rows and cells together. The displayed
rows are updated in place: only the rows a transaction touches are sorted, filtered, grouped, placed in their tree
or pivot, and counted into aggregates again. A transaction that adds or removes most of the rows (more than a
quarter, and over a thousand) builds the view once instead.
Rows and cells together
import type { GridApi } from '@witbybit/wit-grid-react';
type Line = { id: string; qty: number; price: number; total: number };
export function addLineAndDiscount(api: GridApi<Line>) {
return api.transaction({
rows: { add: [{ id: 'l-9', qty: 1, price: 40, total: 40 }] },
cells: [{ rowId: 'l-1', colField: 'price', value: 18 }],
});
}Cell writes are checked before anything commits. If a cell is rejected by validation the whole transaction is rejected and nothing changes.
How a cell write is prepared
Every cell write is prepared the same way, whether it comes from a transaction, an edit, a paste or a view (a card moved on a board, a bar dragged on a timeline):
- Per record, each write applies in order through the column's
valueSetter, on a copy. Every field the setter sets is written, not only its own. - The cell rules of each field the write changes (the written fields, and any other field a setter changed) and
every row rule run once, against the whole proposed record. Fields changed together are judged together: moving
startandendat once passes astart <= endrule that either change alone would fail. A rule that throws reports an issue; a blocking one blocks the write. - What was prepared commits as one change and one undo step.
A setter or rule that returns a promise is awaited by transaction(tx, { async: true }) and by edits and pastes.
A sync transaction cannot wait: it is rejected with needsAsync: true and nothing changes. If a record changes
while an async write waits (a collaborator's change, another write), the write is prepared again against it; if it
changes again, the write is rejected rather than committed over values it never saw. Writes land in call order: a sync
transaction first lets async writes queued before it land, and is prepared against what they leave.
Preview, approve, commit
prepare runs those first two steps and stops: it returns a proposal of what the cell writes would change, and
changes nothing. Show it, ask for approval, then commit it. A proposal commits as prepared, once, on the grid that
prepared it. If one of its records changed in between, commit refuses it with stale: true and writes nothing:
prepare it again to show the new effect. The same holds for what judged it: new rules (data.rules), new
capabilities (for a write made asUser) or a new setter, parse or canEdit on a column it writes refuse it as
stale with the reason "The rules changed since this was prepared." A proposal never commits a silently different
effect: an approval limit lowered while the review is open means the review is prepared and shown again.
import type { GridApi } from '@witbybit/wit-grid-react';
type Invoice = { id: string; status: string; amount: number };
export async function approveMatching(api: GridApi<Invoice>, confirm: (message: string) => Promise<boolean>) {
const { ids, complete } = api.records('matching');
const proposal = await api.prepare(
{ cells: ids.map((rowId) => ({ rowId, colField: 'status', value: 'approved' })), policy: 'bestEffort', asUser: true },
{ async: true }
);
if (!proposal.ready) return proposal.refusal;
const scope = complete ? 'matching' : 'loaded';
const ok = await confirm(`Approve ${proposal.records.length} ${scope} invoices? ${proposal.rejected.length} cannot change.`);
return ok ? api.commit(proposal) : null;
}| Proposal field | What it holds |
|---|---|
ready | Whether committing would change anything |
records | Per record: before, after (setters' effects included) and changes (field, oldValue, newValue) |
rejected | Cell writes that cannot apply, and why (bestEffort: the others are in records) |
refusal | When not ready: what a transaction would have answered (validationFailed with its issues, …) |
added | The records it adds (rowId, row) |
removed | The records it removes, as they are now (rowId, row) |
rejectedRows | Records it cannot add or remove, and why |
A proposal can add and remove records too (client data): rows: { add, addIndex, remove }, remove by id. Cell
writes in the same request may write a record it adds: they are prepared on it (setters, parsing, the rules) and it is
added with them in place, shown once in added. A record
to remove must exist and, with asUser, may be deleted (capabilities.canDelete); a record to add must have a new
id, pass the rules as a whole like an edited record (each field's cell rules, every row rule; an async rule needs
{ async: true }) and, with asUser, may be added (capabilities.canCreate); a record being removed takes no cell
writes. The workspace's quick-add and duplicate go this way, as the user. commit refuses it (stale) if a record it removes changed or an id
it adds was taken meanwhile. Show a delete before it happens:
import type { GridApi } from '@witbybit/wit-grid-react';
type Invoice = { id: string; status: string };
declare const api: GridApi<Invoice>;
declare function confirm(message: string): Promise<boolean>;
export async function deleteDrafts(ids: string[]) {
const proposal = api.prepare({ rows: { remove: ids }, asUser: true, policy: 'bestEffort' });
if (!proposal.ready) return proposal.refusal;
const kept = proposal.rejectedRows.length ? ` (${proposal.rejectedRows.length} kept: ${proposal.rejectedRows[0].reason})` : '';
return (await confirm(`Delete ${proposal.removed.length} invoices${kept}?`)) ? api.commit(proposal) : null;
}prepare takes cell writes and record changes (cells, rows, source, policy, asUser) and calls no backend: persist the committed
change from the changed event or your own save, as for any other write. Like transaction, a sync prepare
meets an async setter or rule with refusal.needsAsync; pass { async: true } to await them.
Drafts: several fields, applied together
A form that edits several fields of one record (an amount, a category and an approver that only make sense together)
should not commit an invalid record in between. api.draft(rowId) holds the edits apart from the grid: nothing is
written, checked, queued for saving or put in the history until the draft is applied, as one change and one undo entry,
through prepare and commit. The handle is the same every time, so a form can call it while rendering; the draft
starts with the first set and ends when it is applied, cancelled or emptied.
import { useGrid, useGridWatch } from '@witbybit/wit-grid-react';
type Invoice = { id: string; amount: number; approver: string };
export function InvoiceForm({ id }: { id: string }) {
const { api } = useGrid<Invoice>();
const draft = useGridWatch((grid) => grid.status.drafts[id] ?? null, { api });
if (!api) return null;
const edit = api.draft(id, { action: 'edit invoice' });
const refusal = draft?.proposal?.refusal;
return (
<form onSubmit={(event) => (event.preventDefault(), void edit.apply())}>
<input value={String(edit.value('amount'))} onChange={(event) => edit.set('amount', Number(event.target.value))} />
<input value={String(edit.value('approver'))} onChange={(event) => edit.set('approver', event.target.value)} />
{refusal && 'reason' in refusal && <p role='alert'>{refusal.reason}</p>}
{draft?.conflicts.length ? <button onClick={() => edit.keep()}>Keep mine</button> : null}
<button type='submit'>Apply</button>
<button type='button' onClick={() => edit.cancel()}>
Cancel
</button>
</form>
);
}getStatus().drafts[rowId] is the draft's state: its values, its status (editing, preparing, ready,
invalid, stale, gone), its latest proposal, and its conflicts: fields the record changed under since they
were drafted (a colleague, the server). Applying refuses while there are any, so a newer value is never written over
silently: keep(field?) keeps yours on what the record holds now, reset(fields?) (a field, a list, or every one)
takes the record's. A prepared draft
goes stale like any proposal when its record or the rules change; prepare() again shows the new outcome. To review
before applying, prepare(), show the proposal, then apply(proposal) commits exactly what was shown. Switching views
or rows never applies a draft: whether to keep, discard or ask is the app's call, reading getStatus().drafts.
A draft's own undo
A draft has its own history, draft.history (getState(), subscribe, undo(), redo()): each set, reset and
keep is one step, and so is each change in a grid or field control bound to the draft (a paste of many lines is one).
Undoing back to nothing drafted ends the draft but keeps what can be redone. Applying makes one step of the grid's
history and ends the draft's; cancelling discards it. The history belongs to the draft, not to a surface: a bound grid
closed and opened again finds it. A change from elsewhere that the draft joins (a teammate's line added under the
draft's lines) starts it afresh, since its older steps no longer describe the field.
In the workspace, undo and redo — the bar's buttons, the keys, the palette — act on the history of the part the person
is working in: in an inspector whose edits are drafts (inspector: { edits: 'draft' }), the record's draft; in the
table or a view, the grid's. A draft with nothing left to undo says so and stops there: it never undoes the grid's
committed edits.
A new record is drafted the same way: api.draft(id, { add: record }) takes the record's starting values, and the
grid does not hold it until the draft is applied (adding: true in its state). Applying adds it with the drafted
values written on it, so setters and parsing run as on an edit, the rules judge the record as it will be added (every
field, written or not), and, made as the user, canCreate is asked about it as it will be. Applied with nothing
drafted, it adds the record as given. If another record takes its id meanwhile, applying refuses it as stale.
import type { GridApi } from '@witbybit/wit-grid-react';
type Invoice = { id: string; vendor: string; amount: number; approver: string };
export function newInvoice(api: GridApi<Invoice>, id: string) {
const draft = api.draft(id, { add: { id, vendor: '', amount: 0, approver: '' }, action: 'new invoice' });
draft.set('vendor', 'Initech');
draft.set('amount', 4_200);
return draft.apply(); // one change: the record added, with both values
}setRows
setRows(rows) takes the full dataset and works out what changed: rows are matched by id, a row passed as the
same object is untouched, a new object for a known id is compared field by field, and ids that appear or
disappear are added or removed. That makes it the natural call for immutable state, such as a store or a React
useState array where you replace one row and keep the rest:
import type { GridApi } from '@witbybit/wit-grid-react';
type Task = { id: string; title: string; done: boolean };
export function toggle(api: GridApi<Task>, tasks: Task[], id: string) {
api.setRows(tasks.map((task) => (task.id === id ? { ...task, done: !task.done } : task)));
}Only the edited row repaints, and selection, scroll position and the rest of the grid are left alone. The cost
follows the change: a row passed as the same object at the same place costs one comparison, and only rows that
differ are read by id. Removing rows with filter or adding them anywhere keeps the order of the others, so they
are placed in place; an array that reorders rows you keep rebuilds the order.
Pass setRows your own rows array, in your own order. To change rows the grid already holds without keeping your
own copy, update them instead: api.transaction({ rows: { update: changed } }) matches rows by id, keeps their
order, and animates rows that move under the current sort. (api.records('displayed').rows returns only the rows currently displayed, in display order — sorted, without filtered-out
rows or rows in collapsed groups — so handing that array back to setRows would reorder your rows and drop the
hidden ones.)
One cell, and the rows' order
One cell is a transaction with one cell write: transaction({ cells: [{ rowId, colField, value }] }), through the
column's validation and editing rules. A formula is a value like any other ('=[o-1:price]*2').
order sets the rows' order by id (client data), after the transaction's row and cell writes and in the same commit:
transaction({ rows: { add: [row] }, order: [row.id, ...api.getRowOrder()] }) adds a row at the top as one undo step.
Async transactions
For data that arrives many times a second, such as a price feed or a websocket stream, queue each change with
transaction(tx, { async: true }). Queued transactions are committed together just before the next frame, and
the returned promise resolves with that transaction's result once it has committed.
import type { GridApi } from '@witbybit/wit-grid-react';
type Quote = { id: string; bid: number; ask: number };
export function connectFeed(api: GridApi<Quote>, socket: WebSocket) {
socket.addEventListener('message', (event) => {
const quote = JSON.parse(String(event.data)) as Quote;
void api.transaction({ rows: { update: [quote] } }, { async: true });
});
}- Order is kept. Transactions are applied in the order they were queued. Any synchronous write, such as
transactionorsetRows, applies the queue first, so writes always land in the order your code made them. - Independent changes are combined. Consecutive row-only transactions that touch different rows are applied
as one transaction: one commit, one pass over the row model and one render. A transaction that touches a row
changed earlier in the same batch, that uses
addIndex, or that writes cells or anorderis applied on its own, so no intermediate change is lost. - Each transaction gets its own result. The promise resolves with the row nodes that transaction added, updated or removed.
- Async validation is awaited. If a cell rule is asynchronous, the transaction waits for it before it is queued, and resolves as rejected if validation blocks it.
- One undo step per batch when transactions were combined.
import type { GridApi } from '@witbybit/wit-grid-react';
type Order = { id: string; status: string };
export async function markFilled(api: GridApi<Order>, order: Order) {
const result = await api.transaction({ rows: { update: [{ ...order, status: 'filled' }] } }, { async: true });
console.log(
'updated',
result.rows.update.map((node) => node.id)
);
}Any synchronous write commits the queue first; otherwise it commits before the next frame. await the promises
when you need the result. Destroying the grid drops anything still queued, and those promises
never resolve.
Timing
By default the queue is committed on the next animation frame. To collect changes over a longer window, set
performance.asyncTransactionWaitMs; the queue is then committed that many milliseconds after
the first queued transaction.
import { Grid } from '@witbybit/wit-grid-react';
type Tick = { id: string; last: number };
export function TickerGrid({ ticks }: { ticks: Tick[] }) {
return (
<Grid rows={ticks} columns={[{ field: 'last', header: 'Last' }]} getRowId={(row) => row.id} performance={{ asyncTransactionWaitMs: 50 }} />
);
}Rules
Row-level writes keep the rules' issues current (Rules and saving). After setRows or a transaction
(sync or async) commits, the rows it changed or added are checked against every cell and row rule, and the rows it
removed drop their issues. This runs after the commit and never blocks the write. Cell writes are different: they are
checked before they commit, and a blocking rule's refusal rejects the transaction.
Row models
Row changes work on rows the grid holds in memory, so they need the client-side row model. With the infinite and
server-side row models the server owns the data: refresh from the server instead (see
Server-side data). On those models a transaction with rows throws an
unsupported-operation error and changes nothing.
Last updated on