Local Data Model
When the rows are already in the browser — a hardcoded array, React state, a client-side collection mutated by user actions — localModel() is the model that wraps them. It holds the array in memory and exposes actions that mutate or derive the rows the table displays.
const model = localModel({ data: products })Pass the model to the table:
<DataTable model={model} style={{ height: 360 }}>
{/* columns */}
</DataTable>Where to keep the model
Section titled “Where to keep the model”The model holds reactive state — engine subscriptions, the pipeline’s stage cache, persisted action payloads. Recreating it discards that state, so the model instance must outlive renders. Two patterns cover almost every case:
Module scope — when the dataset is static and the table is a singleton on the page. This is the simplest possible form and matches how most examples in these docs are written.
import { localModel } from '@virtuoso.dev/data-table'
const model = localModel({ data: products })
export function ProductsTable() {
return <DataTable model={model}>{/* columns */}</DataTable>
}The model is shared by every render and every mount of ProductsTable. Filter and sort state survives navigations away from the page — usually desirable; occasionally not. Initial config can’t depend on props because there’s no component scope yet.
useState with lazy init — inside the component, for everything else: multiple table instances on the same page, initial data sourced from props, dynamic mount/unmount.
const [model] = useState(() => localModel<Product>({ data: initialProducts }))useState’s lazy initializer runs exactly once per mount, and React guarantees the value survives every subsequent render. Reach for this whenever the model can’t live in module scope.
Avoid useMemo(() => localModel(...), []) for this purpose. useMemo is contractually a performance hint — React reserves the right to discard the memoized value, which would silently reset the model’s subscriptions and state. useState is the only hook that guarantees retention.
Pipelines
Section titled “Pipelines”Source rows rarely match what a table should actually display. A search box trims them down, a header click reorders them, a toolbar groups them by category. A pipeline is the ordered chain of stages that derives the rendered rows from the source array — each stage takes the previous stage’s output, transforms it, and hands it to the next one.
Two properties keep the pipeline predictable as actions land:
- The order is declared once, so every action lands in a predictable place instead of racing against the others.
- Each stage’s output is cached, so an unrelated stage doesn’t rerun when only its successor changes.
Declare the stage order on the model, then attach each handler to the stage it belongs to:
const model = localModel({
data: products,
pipeline: ['filter', 'sort'],
actions: {
filter: {
stage: 'filter',
handler: ({ data, payload }) => data.filter((row) => row.category === payload),
},
sort: {
stage: 'sort',
handler: ({ data }) => data.toSorted((a, b) => a.name.localeCompare(b.name)),
},
},
})Stage names are open labels — filter, sort, and group are the conventional ones, but you can split work across whatever stages your table needs (enrich, paginate, dedupe). Inside each handler, data is whatever the previous stage produced: filter sees the source rows, sort sees the filtered rows, a later group stage would see the sorted ones.
When the filter payload changes, the filter stage and the later sort stage both rerun. When only sort changes, the cached filter result is reused — the difference matters once filtering or grouping touches thousands of rows.
Source mutators
Section titled “Source mutators”An action declared without a stage mutates the backing rows. Source mutators invalidate the full pipeline because every derived stage depends on the new source.
const model = localModel({
data: products,
actions: {
renameProduct: {
handler: ({ source, payload }) => source.map((row) => (row.id === payload.id ? { ...row, name: payload.name } : row)),
},
},
})
model.send({ action: 'renameProduct', payload: { id: 'SKU-001', name: 'Desk Pro' } })model.send({ action, payload }) is how buttons, inputs, effects, and table controls dispatch actions. The action name picks the handler; the payload is whatever your handler expects.
React state updates
Section titled “React state updates”When rows arrive from React state, a websocket, or a parent workflow, push the next row set into the model instead of recreating it — see Where to keep the model for why the instance must stay stable.
const [model] = React.useState(() => localModel<Product>({ data: initialProducts }))
React.useEffect(() => {
model.setData?.(products)
}, [model, products])For grouped rows, model.setData(nextRows, nextGroups) updates rows and markers together.
If the row schema changes intentionally, remount the table with a new key so runtime columns can discover the new first result. See Runtime Columns.
Grouping
Section titled “Grouping”When rows naturally cluster — sales by region, products by category, employees by department — group headers belong between the rows they introduce. The library keeps those headers inside the same flat data array rather than nesting rows under each header, so the pipeline, sizing, and virtualization treat them as ordinary rows. A separate groups array tells the table which entries are headers and how deeply they nest.
Static groups
Section titled “Static groups”When the grouping is computed up front, pass pre-grouped rows and markers to localModel:
const rows = [
{ label: 'Office' },
{ id: 'SKU-001', name: 'Standing Desk', category: 'Office' },
{ id: 'SKU-002', name: 'Desk Lamp', category: 'Office' },
{ label: 'Peripherals' },
{ id: 'SKU-003', name: 'USB-C Dock', category: 'Peripherals' },
]
const groups = [
{ index: 0, level: 0 },
{ index: 3, level: 0 },
]
const [model] = useState(() => localModel({ data: rows, groups }))index points at the group row in data. level is the nesting depth — 0 for top-level headers, 1 for headers nested under them, and so on.
Multiple levels
Section titled “Multiple levels”Higher level values render deeper and stick under their parent when the user scrolls. A two-level region → country → city table declares each region marker at level: 0 and each country marker at level: 1:
const data = [
{ groupName: 'North America' },
{ groupName: 'USA' },
{ name: 'New York', population: 8_336_000 },
{ name: 'Los Angeles', population: 3_898_000 },
{ groupName: 'Canada' },
{ name: 'Toronto', population: 2_794_000 },
{ groupName: 'Europe' },
{ groupName: 'UK' },
{ name: 'London', population: 8_982_000 },
]
const groups = [
{ index: 0, level: 0 }, // North America
{ index: 1, level: 1 }, // USA
{ index: 4, level: 1 }, // Canada
{ index: 6, level: 0 }, // Europe
{ index: 7, level: 1 }, // UK
]The table treats level as a depth hint, not a tree. There’s no parent field — a level: 1 header is rendered as a child of whichever level: 0 header most recently preceded it in data, and sticky headers reserve a row per active level on screen. Skipping levels or reordering markers is allowed; the table still renders every row in the order you provided.
Computing groups in a pipeline
Section titled “Computing groups in a pipeline”When the grouping dimension is interactive — “group by category” toggling to “group by status” — keep the source rows flat and compute markers inside a group pipeline stage. The handler returns { data, groups } instead of a bare array, and the pipeline updates rows and markers atomically:
function groupBy<T extends Record<string, unknown>>(rows: T[], field: keyof T | null) {
if (!field) return { data: rows, groups: [] as { index: number; level: number }[] }
const buckets = new Map<string, T[]>()
for (const row of rows) {
const key = String(row[field])
let bucket = buckets.get(key)
if (!bucket) buckets.set(key, (bucket = []))
bucket.push(row)
}
const data: (T | { label: string })[] = []
const groups: { index: number; level: number }[] = []
for (const [key, bucketRows] of buckets) {
groups.push({ index: data.length, level: 0 })
data.push({ label: `${String(field)}: ${key} (${bucketRows.length})` })
data.push(...bucketRows)
}
return { data, groups }
}
const [model] = useState(() =>
localModel({
data: products,
pipeline: ['filter', 'group'],
actions: {
filter: {
stage: 'filter',
handler: ({ data, payload }) => (payload ? (data as Product[]).filter((row) => row.category === payload) : data),
},
group: {
stage: 'group',
handler: ({ data, payload }) => groupBy(data as Product[], payload as keyof Product | null),
},
},
})
)
model.send({ action: 'group', payload: 'category' })Stage order matters here. With ['filter', 'group'], the filter narrows the source rows and the group stage buckets only what survived — the natural pairing. Putting group before filter would force the filter handler to special-case header rows, which is rarely worth the complexity.
A handler that returns { data, groups } updates both. Later stages that return a bare array reuse the most recent groups from earlier in the pipeline, so a sort after group doesn’t need to know about markers as long as it preserves their indexes (typically by sorting within each bucket).
To push pre-grouped rows from outside the pipeline entirely — for example after a parent fetch — call model.setData(nextRows, nextGroups).
Connecting the UI
Section titled “Connecting the UI”<GroupHeaderCell> renders the entries marked as headers. It receives the original row and the marker’s level, so styling can branch on nesting depth:
<DataTable model={model} style={{ height: 360 }}>
<GroupHeaderCell>
{({ row, level }) => (
<div
style={{
paddingLeft: 12 + level * 16,
fontWeight: level === 0 ? 700 : 500,
background: level === 0 ? '#e8e8e8' : '#f4f4f4',
}}
>
{(row.data as { groupName: string }).groupName}
</div>
)}
</GroupHeaderCell>
{/* columns */}
</DataTable>row.data is whatever object you pushed at that index in data — the shape is yours to define, the cell just renders what you return. Group rows are virtualized like every other row, so keep the rendered markup light.
For the full render-side reference — remote-paginated grouping, sticky-header behavior, and a runnable end-to-end example — see Grouped Rows.
Action persistence
Section titled “Action persistence”Persist action payloads — search terms, filter values, sort choice, grouping mode — so user intent survives reloads. Row data is never persisted.
const model = localModel({
data: products,
pipeline: ['filter'],
actions: {
filter: {
stage: 'filter',
persistence: true,
handler: ({ data, payload }) => (payload ? data.filter((row) => row.category === payload) : data),
},
},
})Pass an object instead of true to set a custom storage key or define empty-state handling.
const model = localModel({
data: products,
pipeline: ['filter'],
actions: {
filter: {
stage: 'filter',
persistence: {
key: 'categoryFilter',
isEmpty: (value) => !value,
},
handler: ({ data, payload }) => (payload ? data.filter((row) => row.category === payload) : data),
},
},
})Pair this with modelStatePersistenceAdapter() from State Persistence to save model action state alongside column width, order, and visibility.
isEmpty skips a payload from saved state when it represents “no active filter” — empty strings, "all" selections, null values.