developer guide
Load and Update Data in a JavaScript Data Grid
Rows can be given to the grid outright, fetched straight from a URL as JSON or NDJSON with createUrlSource, or replaced later through rows.load. Changes that arrive afterwards are merged and drawn once a frame, so a busy feed stays smooth instead of repainting per message.
Developer guide › Sources and pushdown › Load and Update Data in a JavaScript Data Grid
Loading and updating data
Data usually arrives after the grid does. Build it empty, then load, the sort, filters, grouping and column layout you set up in the meantime all survive and apply to the new data.
The ordinary sequence
const grid = createGrid(el, { columns, rowKey: 'id', rows: [] });
grid.overlay.show('loading');
const data = await fetch('/api/circuits').then(r => r.json());
grid.rows.load(data);
grid.overlay.hide();
The automatic overlay and per-row treatment for a windowed source
A paged, pushdown or remote source has no rows at
all until its first fetch answers, and later pages arrive one scroll at a time. The grid
handles both gaps itself through loading, without the sequence above: an
overlay for the very first fetch (loading.text, loading.delay,
loading.banner: false to opt back into the pre-1.79 behaviour above), and a
shimmer - or, for a tall unloaded block, one centred "Loading rows from to
to" row - for a page still in flight (loading.rowDelay). Both wait
out their own delay (150ms by default) before drawing anything, so an answer that lands
quickly never flashes either one; a row whose page failed to fetch is marked by name
instead of shimmering forever, and a memory source's synchronous rows never trigger either
path.
A windowed source's first fetch, and a row still in flight
const { createTestDom, flushFrames } = await import('../packages/dom/src/renderer/testdom.js');
const { createGrid } = await import('../packages/dom/src/index.js');
const dom = createTestDom({ width: 400, height: 200 });
let settle;
const grid = createGrid(dom.root, {
rowKey: 'id',
columns: [{ field: 'id' }],
source: { mode: 'paged', pageSize: 5, fetch: () => new Promise((res) => { settle = res; }) },
loading: { delay: 5, rowDelay: 5, text: 'Fetching…', banner: true },
});
flushFrames();
await new Promise((r) => setTimeout(r, 60));
flushFrames();
const overlayEl = dom.root.querySelector('[data-overlay="loading"]');
const banner = overlayEl ? overlayEl.querySelector('.lat-overlay-message')?.textContent : undefined;
const shimmer = dom.root.querySelectorAll('.lat-skeleton').length > 0;
settle({ rows: [{ id: '1' }], total: 1 });
await new Promise((r) => setTimeout(r, 30));
flushFrames();
const after = dom.root.querySelector('[data-overlay]') ? 'still showing' : 'landed';
grid.destroy();
return `banner ${banner} | row shimmer ${shimmer} | after ${after}`;
What an empty grid says
A grid with no visible rows - an empty dataset, or a dataset whose rows are all filtered out - shows an emptyMessage through the ordinary overlay, and takes it down the moment
rows arrive. Omitted, the grid says the catalogue's overlay.noRows for an empty
dataset and overlay.noResults when the filters emptied it. A string replaces both
with one sentence; a function can tell the two apart from the reason it is handed
('noData' or 'filtered') and may return a DOM node instead of text.
The message is never shown while a fetch or a deferred count is still in flight.
Straight from a URL, with createUrlSource
When the data is a file at a URL you do not have to fetch it yourself.
createUrlSource(url, opts) loads a JSON file or streams an NDJSON/JSONL file directly,
and you pass it as the source. See the
Sources reference for every option.
A JSON file, and a streamed NDJSON file
// A JSON file: a top-level array, or nested via rowsPath / map.
createGrid(el, { columns, rowKey: 'id', source: createUrlSource('/data/circuits.json') });
// An NDJSON file: rows stream in as they parse, first rows first.
createGrid(el, { columns, rowKey: 'id', source: createUrlSource('/data/events.ndjson', { batchSize: 500 }) });
// Auth, a nested array, and a 30s refresh:
createGrid(el, {
columns, rowKey: 'id',
source: createUrlSource('/api/rows', {
headers: { Authorization: 'Bearer …' },
rowsPath: 'result.items',
poll: 30000,
}),
});
The format is inferred from the extension, then the Content-Type, then a sniff - override it with format: 'json' | 'ndjson'. A non-2xx response, a network error or a
malformed NDJSON line becomes a source:error event rather than an exception, and
lenient: true skips a bad NDJSON line instead of failing the whole stream.
Incremental changes
rows.load replaces everything. When you have a delta, a websocket message, a
save that returned the updated record: apply just that.
Adds, updates and removals in one call
grid.rows.apply({
add: [{ id: 4, name: 'd' }, { id: 5, name: 'e' }],
update: [{ id: 1, name: 'A' }],
remove: ['3'], // row keys, or the row objects
at: 0, // optional insert position for `add`
});
// → { added: [...], updated: [...], removed: [...] }
An update is a patch. Fields absent from it are untouched, so a delta arriving from a websocket or coming back from a save can be applied as-is without reading the row first. This has to be said explicitly because the opposite: assigning the patch over the row: looks identical for a caller who happens to send whole rows and silently destroys data for one who does not.
Coalescing merges fields rather than keeping the last message. A feed
sending {price} and {volume} as separate messages inside one window
keeps both. Coalescing may reorder work; it may not lose it.
Flushing happens on a frame, with a timer behind it. A queued batch lands
on a paint boundary, which is what makes "ten thousand updates, one repaint" true rather than
usually true, a timer can fire twice between two paints. But
requestAnimationFrame is not guaranteed to fire at all: a backgrounded tab stops
firing it entirely. So a frame and a timer are armed together and the first to arrive wins. In
a foreground tab the frame always wins, at ~16ms against a 50ms fallback; in a hidden tab the
timer keeps the feed applying instead of the grid silently stalling with every caller's
promise unresolved.
A long flush defers rather than blocks. updates.budgetMs caps
how long one flush spends applying; over budget, the remainder returns to the queue and lands
next frame, and the promise a caller is holding resolves when their rows actually land rather
than when the first slice does. Slicing is by row and only for updates, a partially
applied row is not a state the store should be in, and splitting a structural change would
re-run the pipeline twice for one batch. stats().deferrals rising steadily means
the feed is arriving faster than the grid can apply it.
Rejections are reported, never thrown. Throwing would abandon the rows
that were fine. An update or remove naming a row that is not here is unknown-id;
an add whose key already exists is duplicate-id and is refused, because
selection, expansion, comments and the key index all resolve one key to one row and admitting
a second corrupts every one of them at once.
This runs the minimum pipeline. An update touching no sorted, filtered or grouped column skips those stages entirely and only the totals and the affected cells refresh. Adds and removals are structural and re-run everything.
Removals tombstone in place rather than compacting, so every existing index stays valid, which is what lets selection, expansion state and cached permutations survive a delete.
Patching a single cell
When you have a value rather than a row, setCells patches fields in place.
One cell, or many, without row objects
grid.edit.setCells([{ key: 'CIR-100042', colId: 'capacity', value: 990 }]);
grid.edit.setCells([
{ key: 'CIR-100042', colId: 'notes', value: 'Chased' },
{ key: 'CIR-100043', colId: 'capacity', value: 770 },
]);
// → the number of cells written
This is the full path, not a shortcut: it validates, emits cell:changed per cell,
re-sorts if the column is sorted on, records one undo entry, and returns 0 for a
column the user may not write. It then announces the whole call once as
rows:changed (identified: true, edit: true, the
updated rows and the columns written), after the per-cell events, so
a derived grid, a statistic tile or anything else that follows rows:changed
re-reads once for a twenty-cell paste rather than twenty times. An editor commit, a fill, a
paste, an undo, a redo and an optimistic rollback each announce themselves the same way, once
per batch. Earlier releases announced nothing here, and a derived view of an edited grid went
silently stale.
High-frequency updates
For a ticking feed, queue batches changes to the next animation frame so a
thousand messages a second produce sixty repaints rather than a thousand.
A price feed
socket.on('tick', (row) => grid.rows.queue({ update: [row] }));
Walking the data rather than the view
rows.forEach walks what is on screen: filtered, sorted, grouped, with collapsed
rows left out. That is the right default, and the wrong answer for a caller totalling a column,
exporting, or reconciling against another system.
let total = 0;
grid.rows.forEach(r => { total += r.data.amount }); // what the user can see
grid.rows.forEachAll(r => { total += r.data.amount }); // what the grid holds
forEachAll visits leaf rows only, in the order they arrived. Group rows are a
product of the current grouping and do not exist in the data, so they are not offered; the sort
belongs to the filtered view, so the order here is physical rather than sorted.
A remote or paged source holds the page it has fetched, not the whole set, so there is nothing there to walk past the filters. It warns and walks what it has rather than quietly returning the filtered rows, a caller who asked for everything and silently received a subset gets a number that looks entirely plausible and is wrong.
rows.residentCount answers the question a viewer must ask before walking - “how many rows does the grid actually hold?” - without the walk. On a
source that fetches a window (remote or paged) it is the rows that
have loaded, read off the source's block caches in constant-ish time, never the matching total.
Compare it with rows.matchCount: equal means every matching row is resident, smaller
means the grid holds a window and a figure reduced from rows.forEach describes that
window, not the set.
const { createHeadlessGrid } = await import('../packages/core/src/index.js');
const grid = createHeadlessGrid({
rowKey: 'id',
columns: [{ field: 'id' }, { field: 'amount', type: 'number' }],
rows: [{ id: 1, amount: 10 }, { id: 2, amount: 20 }],
});
// A memory grid holds every matching row, so residentCount and matchCount agree.
return grid.rows.residentCount() === 2 && grid.rows.matchCount() === 2;