developer guide
Data Grid Column Definitions
A column names the field it reads and the heading above it. Give one a fixed width or let flex share out the space that is left, hold it between min and max so it never collapses, and size every column to its real contents with grid.columns.fit().
Developer guide › Columns and cell rendering › Data Grid Column Definitions
Defining columns
A column is an object with a field (the path to read from your data) and
whatever else it needs. Everything except field or id is optional.
field reads a dot path, so nested data needs no flattening. The column's
id defaults to the field, which is what you use everywhere else: in
setCells, in filters, in saved state.
Grouped headers
Nest columns to get a spanning header row.
Two levels
columns: [
{ field: 'circuitId', title: 'Circuit' },
{ title: 'Location', children: [
{ field: 'region' },
{ field: 'country' },
{ field: 'site.address.postcode', title: 'Postcode' },
]},
]
Computed columns
A column can compute its value instead of reading one. Declare what it depends on and the
grid builds a dependency graph, so editing cost invalidates margin
and nothing else.
Derived values
{
id: 'margin',
title: 'Margin',
type: 'number',
format: 'currency:GBP:2',
value: {
deps: ['monthlyCharge', 'cost'],
compute: (deps) => deps.monthlyCharge - deps.cost,
},
}
// compute(deps, ctx): deps holds the resolved values of the columns you
// named; ctx carries { data, row, column, grid, context } when you need more.
compute is handed the values it declared rather than the whole row, which is
what lets the grid memoise it: the same inputs give the same answer, so the result is cached
until one of them changes. Reach for ctx.data when you genuinely need the rest of
the row, and set pure: false if the result depends on something the grid cannot
see.
Declaring deps is what makes this cheap. Without it the grid would have to
assume any change might affect any computed column and recompute all of them on every edit.
A cycle is caught at compile time with the full path named, rather than becoming a stack
overflow at render time.
When a computed value is re-run, stated plainly:
- A pure compute (the default) runs at ingest and is cached. It runs again when its row is
replaced through
rows.apply({ update })or the data throughrows.load()- unconditionally, since a value derived from data that is gone is stale by definition; when the grid a derived grid follows changes; and when you ask withrows.refresh({ rows, columns, force: true }). A sort, a filter, a state restore or an edit to a column outside itsdepsdoes not re-run it. An in-place cell edit to one of itsdepsdoes not currently re-run it either. - Naming a column re-runs what depends on it:
refresh({ columns: ['p'], force: true })recomputes aqwhosedepsincludep;refresh({ columns: ['q'] })does not recomputep. pure: falseguarantees the compute is re-evaluated on every read and every paint. It is never served from a cache.- Whenever a compute re-runs,
rows.text()and the painted cell show the new result, and so do a sort or filter on the column: every cache the grid keeps for that cell - the one behind the text and the paint, and the one sort and filter handles read - is invalidated together. - Without
force, a targetedrefresh({ rows, columns })behaves differently by store mode, and which one you get flips atcolumnarBelow. On a grid below that row count the named cell is re-run on its next read; on a columnar one the stored value stands until you passforce: true. This is behaviour to plan for, not a tuning detail: the same call on the same data recomputes or does not purely according to how many rows arrived. Passforce: truewhen you want the same answer whatever the row count.
An answer that arrives later
// A lookup the grid cannot see: show a placeholder, fill the table, then
// tell the grid which cells to recompute. The compute stays pure, so it is
// not re-run on every paint - only when you say the answer changed.
const names = new Map();
const column = {
id: 'owner', title: 'Owner',
value: {
deps: ['ownerId'],
compute: (deps) => names.get(deps.ownerId) ?? 'Loading…',
},
};
const missing = [...new Set(grid.rows.data().map((r) => r.ownerId))].filter((id) => !names.has(id));
const resolved = await fetchNames(missing); // { id: name }
for (const id of missing) names.set(id, resolved[id]);
const rows = grid.rows.data().filter((r) => missing.includes(r.ownerId)).map((r) => r.id);
grid.rows.refresh({ rows, columns: ['owner'], force: true });
// Or declare the column `pure: false` and it re-reads `names` on every
// paint; then a plain grid.rows.refresh() after the fetch is enough.
Sizing and pinning
Layout
{ field: 'circuitId', layout: { width: 130, pin: 'start' } }
{ field: 'notes', layout: { flex: 1, min: 200 } }
{ field: 'isActive', layout: { width: 90, pin: 'end' } }
Pinned columns are rendered in their own region and do not scroll horizontally.
grid.columns.fit() distributes the viewport width across visible columns, and
autoSize measures content.
The width fit() distributes is the space the cells actually occupy: the body
viewport’s client width, read when you call it. When the grid has enough rows to
scroll vertically, that width excludes the scrollbar, so the columns end flush with it
instead of running under it; when there is no vertical scrollbar, it is the full inner
width. Rows you passed to createGrid or grid.rows.load() before
the call are counted, so calling it straight after either works.
Every column the grid draws counts toward that width, not only the ones fit()
sizes. It sizes your resizable columns. A column it does not size keeps its width, and that
width comes off the target first: a column declared resizable: false, and the
grid’s own selection checkbox, detail expander, group and tree columns. Your resizable
columns then share what is left in proportion to their current widths, within each
min and max; a column held at a bound stays there and the others
share the rest, so all the drawn columns together still come to the viewport width
exactly. Under a pivot every column drawn is one the grid generates, so fit()
has nothing to size and leaves the widths as they are.
If what is left is less than those columns’ minimums, which happens when the columns
fit() does not size already take the width, each resizable column is set to its
min (40px when it declares none). None goes below its minimum, and none is
squeezed to nothing: the grid scrolls horizontally instead, and a [lattice]
warning in the console names the widths that ran out.
fit() is one-shot. It sets a fixed width on each column once, including a
flex column, and does not follow the grid afterwards. If the width changes
later, because the container is resized or because rows that arrive afterwards bring a
vertical scrollbar in, call it again. A column that should keep tracking the width by
itself wants flex instead of fit().
Both are also on the column menu: Move left, Move right, Move to start, Move to end, and a Width submenu, and bound to the keyboard with a heading focused: Alt with a left or right arrow resizes, Shift with one moves the column. Neither operation depends on dragging.
resizePreview: 'deferred' changes what a column-border drag shows while
it is in progress: the default, 'live', resizes the column as the pointer moves;
'deferred' draws a guide line at the pointer instead and commits the new width
only on release. The keyboard resize above is unaffected either way - there is nothing
to defer when a keypress sets the final width directly. Worth setting on a grid wide enough
that a live resize repaints more than the drag is worth.
A pinned region holds the edge of the viewport only while there is something to scroll. Where
the columns are narrower than the grid: fixed widths, or a flex column that
has reached its max: nothing scrolls, so the pinned columns sit directly
after the centre ones and the spare width falls beyond them all, at the right of the grid.
To take that space up rather than leave it, give a column flex and no
max, or call grid.columns.fit(). Note that removing a column’s
width does not do it: a column with neither width nor
flex takes the default 150 rather than a share of what is free. Absorbing space is
what flex is for, and min only ever sets a floor.