Lattice Grid Buy a licence

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 guideSources 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();

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.