Value Getters & Formatters
Three hooks decide what a cell holds, how it reads, and how an edit is written back. The rule behind most of the bugs: a formatter changes the text and nothing else, so sorting, column filters and editing keep working on the real value.
Value getters
A column with no field of its own. Full Name is built from two fields, Years is computed — and both sort and filter like real ones.
When you need one
Reach for valueGetter when the column has no field to point at. Full
Name is built from two fields; Years is arithmetic on a third. Neither
exists on the row, and neither needs to.
{
key: 'fullName', name: 'Full Name',
valueGetter: row => `${row.firstName} ${row.lastName}`,
sortable: true, filter: { type: 'text' },
}
A getter replaces prop. Set both and the getter wins, so delete the
prop rather than leaving it there to be read later as if it mattered.
It is a real column
Because the getter produces the value itself, everything downstream sees it. Sort Full Name and the rows order by "Alice Smith". Type into its filter and the search runs over the same text. Sort Years and the order is numeric, because the getter returned a number and nothing has turned it into a string yet.
Value formatters
The same value, written for a human. Sort Salary, edit it, then type Jan 2016 into the Started filter and again into the search box — one grid walks the whole formatted-versus-raw rule.
Display only — and that is the point
Salary reads as $92,000. Sort it: the order is numeric, not
alphabetical. Its filter is a number box, so you type 92000 — the value, never the
text. Double-click a cell: the editor opens on 92000 too.
That is the whole rule in one column. A grid that formatted before it sorted would put
$1,000 before $90, which is the classic version of this bug.
Two boxes, one column, two different answers
Started shows Jan 2016 and stores 2016-01-10. Both
boxes below take text, so nothing but the value they read separates them:
- Type
Jan 2016into the column filter under Started — no rows. It compares against the stored date. - Type
Jan 2016into the quick-filter box above the grid — the rows appear. It searches what is on screen.
That is deliberate rather than an inconsistency: someone typing into a search box is copying what
they can see, while a column filter is a comparison against the real value. Type
2016-01 into the column filter and it matches.
Keep them pure and cheap
A formatter runs for every visible cell on every render. Build the
Intl.NumberFormat once outside the column definition, not inside the function. And
do not read anything that can change underneath it — a formatter that reaches for the clock will
disagree with itself between two renders. Years above is measured against a
fixed date for exactly that reason.
A formatter that throws does not take the grid down: the cell falls back to the raw value, and one warning is logged for the column rather than one per cell.
Value setters
Writing an edit back to a nested field. Email lives at contact.email; Department does not, and needs no setter at all.
Last committed edit:Edit an email cell — the write goes through this column's valueSetter.
Reading is free, writing is not
prop: 'contact.email' reads the nested field with no code at all — the cells above
are proof. Writing is where it breaks. The default write is a shallow set on
prop, so committing an edit would put the string where the
contact object used to be.
valueSetter: (row, value) => ({
...row,
contact: { ...row.contact, email: String(value) },
})
The setter returns a new row. That is what keeps the change visible to everything watching, and it is why the row is spread rather than mutated.
Most columns need no setter
Department above is editable and has no valueSetter. It does not
need one: the field sits at the top level, so the default shallow write is already correct.
Add a setter when the default would be wrong — which in practice means anything nested, or a
value that has to be parsed before it is stored.
cellEditCommit hands you the row the setter returned, so the object you send to
your API is the one the grid is showing.
Three hooks, three different jobs
A cell's text is produced in stages, and each stage has its own hook. Knowing which one to reach for is most of the subject:
valueGetter— what the value is. Use it when a column has no field of its own: a combination of two fields, something computed, something nested. It replacesprop.valueFormatter— how the value is printed. It changes the text and nothing else; the underlying value is untouched.valueSetter— how an edit is written back. Use it when the default write would be wrong, which is mainly anything nested.
So the question to ask is: is this a different value, or the same value written differently? Different value → getter. Same value → formatter.
The rule that prevents the bugs: formatting is display-only
A formatted column still sorts, filters and edits as its raw value. What sees the formatted text is the reader — and the two features whose job is to match what the reader can see:
| Uses the formatted text | Uses the raw value |
|---|---|
| the cell itself | sorting |
| the quick-filter box | column filters |
| an exported CSV file | editing — the editor opens on the raw value |
The second example below walks every row of that table on one grid. If you need a file to carry something other than the on-screen text, decide it in the export rather than in the column — see What Gets Exported.
Sorting a computed column
The examples here use sortMode: 'client', so the grid sorts the rows it has loaded
using the getters. With the default server-side sorting your endpoint decides the order, and it
has never heard of a column called Full Name — so either translate the sort key on the
way out, or put the column in client mode. It is worth choosing deliberately: a computed column
with a server-side sort that silently does nothing is easy to ship.
filterApplyMode: 'client' works the same way and carries the same caveat: it filters
the rows the grid has, so pair it with scrollMode: 'infinite' rather than
with paging.
A valueGetter receives the column as its second argument, so one function can serve
several columns — read column.key and branch, instead of writing near-identical
getters.
Value pipeline options
| Property | Type | Default | Description |
|---|---|---|---|
| column.valueGetter | (row, column) => any | — | Produces the column's value. Replaces prop, and everything downstream — sorting, filtering, editing — uses what it returns. |
| column.valueFormatter | (value, row, column) => string | — | Turns the resolved value into the text shown in the cell. Display only: sorting, column filters and editing keep using the raw value. A throwing formatter falls back to the raw value and warns once per column. |
| column.valueSetter | (row, value) => T | shallow set on <code>prop</code> | Writes a committed edit back and returns a new row. Needed for nested fields, where the default write would replace the parent object. |
| column.prop | keyof T | string | — | The field to read, including a dot path such as contact.email. Ignored when a valueGetter is present. |
| config.sortMode | 'server' | 'client' | 'server' | Where sorting happens. 'client' sorts loaded rows with the columns' getters — which is what a computed column needs. |
| config.filterApplyMode | 'server' | 'client' | 'server' | Where filtering happens. 'client' filters the loaded rows against each column's resolved value, and requires scrollMode: 'infinite'. |
| config.quickFilter | boolean | QuickFilterConfig | false | The search box that matches the formatted on-screen text across every column. |
| column.editable | boolean | false | Lets a cell be edited; the committed value goes through valueSetter. See Cell Editing. |