Skip to content

State Persistence Example

A remote category filter and the column widths both survive a reload. The model action opts into persistence with persistence: true; the column-width adapter saves resize overrides by field name. Both restore on mount through DataTableStatePersistence.

  • DataTableStatePersistence — mounts the adapters under one storage key
  • modelStatePersistenceAdapter() — persists model action state
  • columnWidthPersistenceAdapter() — persists column width overrides
  • persistence: true — opt-in flag on a model action
import { useState } from 'react'

import { Button } from '@/components/ui/button'
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader, HeaderEdge } from '@/components/ui/data-table'
import { ResizeHandle } from '@/components/ui/data-table/column-resize'
import { columnWidthPersistenceAdapter } from '@virtuoso.dev/data-table/column-resize'
import { defaultOffsetViewportHandler, remoteModel } from '@virtuoso.dev/data-table'
import { DataTableStatePersistence, modelStatePersistenceAdapter } from '@virtuoso.dev/data-table/state-persistence'

import { fetchProducts, placeholder } from './api'

import type { Product, Query } from './api'

const adapters = [modelStatePersistenceAdapter(), columnWidthPersistenceAdapter()]

export default function App() {
  const [model] = useState(() =>
    remoteModel<Product, Query>({
      actions: {
        filter: {
          persistence: true,
          handler: ({ params, payload }) => ({ ...params, category: String(payload) }),
        },
      },
      fetch: fetchProducts,
      initialParams: { category: 'all' },
      onViewportChange: defaultOffsetViewportHandler,
      pageSize: 40,
      placeholder,
    })
  )

  function applyCategory(nextCategory: string) {
    model.send({ action: 'filter', payload: nextCategory })
  }

  return (
    <div className="space-y-4">
      <div className="flex flex-wrap gap-2">
        {['all', 'Office', 'Peripherals', 'Audio'].map((value) => (
          <Button key={value} onClick={() => applyCategory(value)} variant="outline">
            {value}
          </Button>
        ))}
      </div>

      <DataTable className="rounded-xl" model={model} style={{ height: 420 }}>
        <DataTableStatePersistence adapters={adapters} storageKey="docs-state-persistence-example" />
        <DataTableColumn field="name">
          <DataTableColumnHeader>
            <HeaderEdge component={ResizeHandle} />
            {() => 'Product'}
          </DataTableColumnHeader>
          <DataTableCell className="font-medium">{({ row }) => row.data.name}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="category">
          <DataTableColumnHeader>
            <HeaderEdge component={ResizeHandle} />
            {() => 'Category'}
          </DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="price">
          <DataTableColumnHeader className="justify-end">
            <HeaderEdge component={ResizeHandle} />
            {() => 'Price'}
          </DataTableColumnHeader>
          <DataTableCell className="text-right tabular-nums">{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
      </DataTable>
    </div>
  )
}

Restore happens after columns and the model register with the engine, so a brief unpersisted render is normal on mount. Keep the adapters array stable (define outside the component or wrap in useMemo), and bump storageKey or resetKey when saved state should no longer apply.