State Persistence
DataTableStatePersistence saves column width, order, visibility, and model action state under a storage key, restoring them on mount. With it wired up, a user’s resize, hide, drag, or filter survives a reload. Two requirements: mount the component inside the table, and pass an adapter for each feature you want persisted.
Minimal setup
Section titled “Minimal setup”import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader, HeaderEdge } from '@/components/ui/data-table'
import { ResizeHandle } from '@/components/ui/data-table/column-resize'
import { columns$, localModel, useCellValue, usePublisher } from '@virtuoso.dev/data-table'
import { columnWidthPersistenceAdapter } from '@virtuoso.dev/data-table/column-resize'
import {
columnVisibilityPersistenceAdapter,
columnVisibilityState$,
setColumnVisibility$,
} from '@virtuoso.dev/data-table/column-visibility'
import { DataTableStatePersistence } from '@virtuoso.dev/data-table/state-persistence'
const rows = [
{ id: 'SKU-001', product: 'Standing Desk', category: 'Office', stock: 14, internalId: 'inv-001' },
{ id: 'SKU-002', product: 'USB-C Dock', category: 'Peripherals', stock: 42, internalId: 'inv-002' },
{ id: 'SKU-003', product: 'Mechanical Keyboard', category: 'Peripherals', stock: 28, internalId: 'inv-003' },
{ id: 'SKU-004', product: 'Noise-canceling Headset', category: 'Audio', stock: 9, internalId: 'inv-004' },
]
const model = localModel({ data: rows })
const adapters = [columnVisibilityPersistenceAdapter(), columnWidthPersistenceAdapter()]
function ColumnPicker() {
const columns = useCellValue(columns$)
const visibility = useCellValue(columnVisibilityState$)
const setColumnVisibility = usePublisher(setColumnVisibility$)
return (
<div className="mb-3 flex flex-wrap gap-2 rounded-md border bg-muted/30 p-3 text-sm">
{[...columns].map(([key, column]) => {
const visible = visibility.get(key) ?? column.visible !== false
return (
<button
aria-pressed={visible}
className={visible ? 'rounded border bg-background px-2 py-1 font-medium' : 'rounded border px-2 py-1 text-muted-foreground'}
key={key}
onClick={() => setColumnVisibility({ key, visible: !visible })}
type="button"
>
{visible ? 'Hide' : 'Show'} {column.field}
</button>
)
})}
</div>
)
}
export default function App() {
return (
<DataTable className="rounded-xl" computeRowKey={({ data }) => data.id} model={model} style={{ height: 360 }}>
<DataTableStatePersistence adapters={adapters} storageKey="docs-state-persistence-feature-page" />
<ColumnPicker />
<DataTableColumn field="product">
<DataTableColumnHeader>
<HeaderEdge component={ResizeHandle} />
{() => 'Product'}
</DataTableColumnHeader>
<DataTableCell className="font-medium">{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="category">
<DataTableColumnHeader>
<HeaderEdge component={ResizeHandle} />
{() => 'Category'}
</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="stock">
<DataTableColumnHeader className="justify-end">
<HeaderEdge component={ResizeHandle} />
{() => 'Stock'}
</DataTableColumnHeader>
<DataTableCell className="text-right tabular-nums">{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="internalId" visible={false}>
<DataTableColumnHeader>
<HeaderEdge component={ResizeHandle} />
{() => 'Internal ID'}
</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
</DataTable>
)
}Reload after hiding a column or resizing a header — the adapters you passed restore that state.
Adapters
Section titled “Adapters”| Adapter | Subpath | Persists |
|---|---|---|
modelStatePersistenceAdapter() | /state-persistence | Action payloads from localModel() / remoteModel() (filters, sort, group) |
columnOrderPersistenceAdapter() | /column-reorder | Column order, keyed by field |
columnWidthPersistenceAdapter() | /column-resize | Width overrides, keyed by field |
columnVisibilityPersistenceAdapter() | /column-visibility | Visibility overrides (only those differing from the declarative default), keyed by field |
import { columnOrderPersistenceAdapter } from '@virtuoso.dev/data-table/column-reorder'
import { columnWidthPersistenceAdapter } from '@virtuoso.dev/data-table/column-resize'
import { columnVisibilityPersistenceAdapter } from '@virtuoso.dev/data-table/column-visibility'
import { DataTableStatePersistence, modelStatePersistenceAdapter } from '@virtuoso.dev/data-table/state-persistence'
const adapters = [
columnVisibilityPersistenceAdapter(),
columnOrderPersistenceAdapter(),
columnWidthPersistenceAdapter(),
modelStatePersistenceAdapter(),
]
<DataTable model={model} style={{ height: 420 }}>
<DataTableStatePersistence adapters={adapters} storageKey="inventory-table" />
{/* columns */}
</DataTable>Storage defaults to window.localStorage. Pass storage for sessionStorage, a test double, or an application-specific backend.
The adapters array must be stable — define it outside the component or wrap it in useMemo() so the persistence component doesn’t resubscribe on every render.
Storage keys and resets
Section titled “Storage keys and resets”storageKey identifies one logical table. Change it when the saved state should no longer apply (different product surface, breaking schema change).
resetKey clears saved state without changing the identity — bump it when the user explicitly resets a view.
<DataTableStatePersistence adapters={adapters} resetKey={resetVersion} storageKey="inventory-table" />Adapters preserve unmatched entries where they can, so state survives partially-matching dynamic schemas.
Field names are the persistence contract
Section titled “Field names are the persistence contract”The adapters above all key their saved state by field. A column’s width entry is “the width for the column whose field is 'price'”, not “the width for the column in position 2”. Same for order, visibility, and sticky pin. That’s what makes saved state survive schema migrations, partial column lists, and dynamic column generation — the field name is the contract between the column declaration and the saved entry.
Two consequences fall out of that:
Don’t rename field lightly. A column declared as field="price" last week and field="cost" this week is two different columns from persistence’s perspective. The width entry under 'price' doesn’t transfer to 'cost', and the user’s saved width silently goes back to the default. For schema migrations that have to rename, plan a one-time migration step that rewrites saved entries from the old key to the new one.
Keep field separate from header copy. Header text is presentation and changes freely (locale switches, A/B tests, restyling). field is identity and must stay the same:
<DataTableColumn field="customerName">
<DataTableColumnHeader>{locale === 'de' ? 'Kunde' : 'Customer'}</DataTableColumnHeader>
</DataTableColumn>This separation is why persisted state survives locale changes — the saved width sits under 'customerName' regardless of what the header reads in any given language.
Model action persistence
Section titled “Model action persistence”Only actions marked with persistence are saved. Add it to a pipeline action for local models, or to an action that transforms request params for remote models.
const model = remoteModel({
fetch: fetchProducts,
initialParams: { status: 'all' },
actions: {
filter: {
persistence: { isEmpty: (value) => value === 'all', key: 'status' },
handler: ({ params, payload }) => ({ ...params, status: payload }),
},
},
})The adapter saves action payloads, not row data. On restore, the model replays the action and rebuilds the request or local pipeline result.
See the State Persistence example for a remote table wiring model and column-width persistence together.