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

MethodUse 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];
}
FieldEffect
rows.addRows to insert. They are appended in source order unless rows.addIndex is set.
rows.addIndexSource position to insert the added rows at.
rows.updateReplacement row objects. Each replaces the row with the same id; rows that do not exist are ignored.
rows.removeRows to remove, matched by id. Only the id needs to be right.
cellsCell writes, { rowId, colField, value }, through each column's value setter, formulas and validation.
sourceWhere the cell writes come from: 'api' (default), 'paste' or 'fill'. Picks the validation policy.
policyCell writes that cannot apply: 'atomic' (default) rejects them all; 'bestEffort' applies the others and lists the rest in cells.rejected.
asUserCheck the user's capabilities for each cell, as an edit or paste does. The api itself is not limited by them.
orderThe 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 its issues) or 'failed', each with a reason.
  • rows: the public row nodes that were added, updated and removed.
  • cells: committed (the writes that landed) and rejected (each with the reason).

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):

  1. 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.
  2. 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 start and end at once passes a start <= end rule that either change alone would fail. A rule that throws reports an issue; a blocking one blocks the write.
  3. 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 fieldWhat it holds
readyWhether committing would change anything
recordsPer record: before, after (setters' effects included) and changes (field, oldValue, newValue)
rejectedCell writes that cannot apply, and why (bestEffort: the others are in records)
refusalWhen not ready: what a transaction would have answered (validationFailed with its issues, …)
addedThe records it adds (rowId, row)
removedThe records it removes, as they are now (rowId, row)
rejectedRowsRecords 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 transaction or setRows, 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 an order is 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.

Edit on GitHub

Last updated on

On this page