Nested fields (address.city)
field: 'address.city' reads and writes nested objects: display, sort, filter, edit, paste, undo, change tracking and export all follow the path.
Live demoNested fields
Edit address.city and see the row object change.
Code for this demo
import { createGrid, type Row } from '@freegridjs/core';
import { makeRows } from '../data';
import { button, separator, text, tr, type DemoContext } from '../ui';
const CITIES = ['Seoul', 'Busan', 'Incheon', 'Daegu', 'Tokyo', 'Paris'];
export default function mount(ctx: DemoContext) {
const h = (en: string, ko: string) => tr(ctx.lang, { en, ko });
// nested rows, as an API sends them; every fifth customer has no address yet
const data: Row[] = makeRows(300).map((r, i) => ({
id: r.id,
customer: { name: r.name, company: r.company, ...(i % 5 ? { address: { city: CITIES[i % CITIES.length], zip: String(10000 + i * 37).slice(0, 5) } } : {}) },
total: r.q1,
}));
const grid = createGrid(ctx.host, {
columns: [
{ field: 'id', header: 'ID', type: 'number', width: 70 },
{ field: 'customer.name', header: h('Customer', '고객'), width: 160 },
{ field: 'customer.company', header: h('Company', '회사'), width: 150 },
{ field: 'customer.address.city', header: h('City', '도시'), width: 120, editable: true, editor: { type: 'combo', options: CITIES } },
{ field: 'customer.address.zip', header: h('ZIP', '우편번호'), width: 100, editable: true },
{ field: 'total', header: h('Total', '합계'), type: 'number', format: '#,##0', width: 110 },
],
data, locale: ctx.lang, getRowId: r => String(r.id), filterRow: true,
});
grid.on('cellValueChanged', e => ctx.log(`#${e.row.id} ${e.colId}: ${String(e.oldValue ?? '')} → ${String(e.newValue)} · customer = ${JSON.stringify(e.row.customer)}`));
text(ctx, h('Edit a city or ZIP: the log shows the nested object. Rows without an address get one. Sort, filter and Ctrl+Z work as usual.',
'도시나 우편번호를 고쳐 보세요. 로그에 중첩 객체가 보입니다. 주소가 없던 행에는 주소가 생깁니다. 정렬, 필터, Ctrl+Z도 그대로 됩니다.'));
separator(ctx);
button(ctx, h('Changes', '변경 내역'), () => ctx.log(JSON.stringify(grid.getChanges().map(c => ({ id: c.row.id, colId: c.colId, old: c.oldValue, new: c.newValue })))));
button(ctx, h('Revert all', '모두 되돌리기'), () => grid.revertChanges());
return grid;
}Data from an API is often nested: an order has a customer object, the customer an address. Write the path to the value as the column's field, with dots between the keys, and the grid reads and writes it there. There is no need to flatten the rows first or to write a valueGetter.
const rows = [
{ id: 1, customer: { name: 'Kim', address: { city: 'Seoul', zip: '04524' } }, total: 1200 },
{ id: 2, customer: { name: 'Lee', address: { city: 'Busan' } }, total: 800 },
{ id: 3, customer: { name: 'Park' }, total: 450 }, // no address yet
];
createGrid(el, {
getRowId: row => String(row.id),
columns: [
{ field: 'customer.name', header: 'Customer' },
{ field: 'customer.address.city', header: 'City', editable: true },
{ field: 'customer.address.zip', header: 'ZIP', editable: true },
{ field: 'total', type: 'number' },
],
data: rows,
});What follows the path
| Feature | With a nested field |
|---|---|
| Display, sort, filter, quick search, find | Read the value at the path; a missing object on the way reads as blank |
| Editing, paste, fill, Delete | Write into the nested object; missing objects on the way are created (row 3 above gets customer.address) |
| Undo, revertChanges | Put the old value back; revertChanges also removes objects the edits created |
| Change tracking | getChanges() gives the path as the column and the old and new value; the original keeps its own copy of nested objects, so it is not changed by later edits |
| applyTransaction (live data) | An update that sends { id, customer: { … } } replaces the object; the nested columns repaint, flash and re-sort |
| CSV, Excel, PDF, print, import | Export the value at the path; an imported file fills the nested objects |
| Column id, state, filters | The id is the path ('customer.address.city') unless you give id |
Keys that contain a dot
When a row has a key that is the whole path — { 'customer.name': 'Kim' }, as some exports produce — that key wins and is read and written as one flat key. So data that is already flat keeps working.
Nested field or valueGetter?
| field: 'a.b' | valueGetter | |
|---|---|---|
| Read | yes | yes |
| Edit, paste, fill | yes | no |
| Values from several fields | no | yes (row => row.qty * row.price) |
Arrays (items[0].name) | 'items.0.name' reads the first item | yes |
With TypeScript
With typed rows the paths are completed and checked by the editor: ColumnDef<Order> accepts customer.address.city and refuses a typo. See TypeScript.
A path walks plain objects and arrays (items.0.qty). A Date or a class instance at the end is the value itself. Change tracking and undo keep their own copy of plain objects and arrays only: a class instance is shared, so an edit inside one (field: 'owner.name' on a class) is not seen as a change. Keep values you edit as plain objects.
Questions about this page
How do I show a nested property such as address.city in a column?
Write the path as the field: { field: 'address.city' }. It is read, sorted, filtered and edited like any field, and an edit writes back into row.address.city.
Do I still need a valueGetter for nested data?
Not for plain paths. A valueGetter can only read, so the column cannot be edited; a dotted field can. Keep valueGetter for values computed from several fields.