Skip to content

Controlling the Table

External UI — toolbars, settings menus, status badges — often needs to read the table’s state (current scroll position, visible columns, loading phase) and trigger its actions (scroll to a row, toggle a column, refresh data). The table exposes both through its internal reactive runtime; external UI taps that runtime directly instead of receiving lifted state through props.

Two pieces wire the connection. The first is an identifier for the table instance: useEngineRef() returns a ref for UI in the same component, and a string engineId reaches a table mounted elsewhere in the tree. The second is the channels the identifier carries — state cells like scrollLocation$ and columns$ expose values you read with useRemoteCellValue, and action streams like scrollToRow$ and setColumnVisibility$ accept commands you publish with useRemotePublisher. The $ suffix marks both flavors.

Reach for engineRef when the external UI lives in the same component as the table — a toolbar two divs up, a status panel rendered as a sibling. useEngineRef() returns an opaque ref; pass it to <DataTable engineRef={engineRef}> and to the remote hooks beside it, and they resolve to the same engine.

import { Button } from '@/components/ui/button'
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import { localModel, scrollLocation$, scrollToRow$, useEngineRef, useRemoteCellValue, useRemotePublisher } from '@virtuoso.dev/data-table'

const rows = Array.from({ length: 120 }, (_, index) => ({
  id: `SKU-${String(index + 1).padStart(3, '0')}`,
  name: `Product ${index + 1}`,
  category: ['Office', 'Peripherals', 'Audio'][index % 3]!,
}))

const model = localModel({ data: rows })

export default function App() {
  const engineRef = useEngineRef()
  const scrollLocation = useRemoteCellValue(scrollLocation$, engineRef)
  const scrollToRow = useRemotePublisher(scrollToRow$, engineRef)

  return (
    <div className="space-y-4">
      <div className="flex flex-wrap gap-2">
        <Button onClick={() => scrollToRow({ index: 0, behavior: 'smooth' })} variant="outline">
          Top
        </Button>
        <Button onClick={() => scrollToRow({ index: 80, align: 'center', behavior: 'smooth' })} variant="outline">
          Center row 81
        </Button>
      </div>

      <div className="rounded-lg border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
        {scrollLocation === undefined ? 'Waiting for scroll state...' : `Visible height: ${Math.round(scrollLocation.visibleListHeight)}px`}
      </div>

      <DataTable className="rounded-xl" model={model} engineRef={engineRef} style={{ height: 360 }}>
        <DataTableColumn field="name">
          <DataTableColumnHeader>Product</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="category">
          <DataTableColumnHeader>Category</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
      </DataTable>
    </div>
  )
}

Mount order doesn’t matter. The hooks register against the ref immediately and start receiving values as soon as the table’s engine comes up.

Reach for engineId when the external UI lives far from the table — a page-level toolbar in the layout shell, a sibling panel that doesn’t share a parent component with the table, anything where threading a ref through props would mean prop-drilling through unrelated levels. Pick a stable string, pass it as <DataTable engineId={…}>, and hand the same string to the hooks anywhere in the tree.

import { Button } from '@/components/ui/button'
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import { scrollToRow$, useRemotePublisher } from '@virtuoso.dev/data-table'

const TABLE_ENGINE_ID = 'inventory-table'

function PageToolbar() {
  const scrollToRow = useRemotePublisher(scrollToRow$, TABLE_ENGINE_ID)

  return (
    <Button onClick={() => scrollToRow({ index: 0, behavior: 'smooth' })} variant="outline">
      Top
    </Button>
  )
}

function InventoryTable({ rows }) {
  return (
    <DataTable model={model} engineId={TABLE_ENGINE_ID} style={{ height: 420 }}>
      <DataTableColumn field="name">
        <DataTableColumnHeader>Product</DataTableColumnHeader>
        <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
      </DataTableColumn>
    </DataTable>
  )
}

Use a unique ID per table on a given screen (inventory-table, transactions-table). The registry holds one engine per ID, so a second table mounting with the same engineId silently overwrites the first — and when the first unmounts, the registry entry is cleared out from under the second. The symptoms are confusing; the prevention is trivial.

Filtering, sorting, grouping, and search often live in a toolbar beside the table. Keep that toolbar connected to the model through the table engine: publish the user action with dispatchModelAction$, then read modelActionState$ to decide which control is active. The toolbar does not need the model handle, and it does not need to keep a separate React state value in sync with the model.

import { useState } from 'react'

import { Button } from '@/components/ui/button'
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader } from '@/components/ui/data-table'
import {
  dispatchModelAction$,
  localModel,
  modelActionState$,
  useEngineRef,
  useRemoteCellValue,
  useRemotePublisher,
} from '@virtuoso.dev/data-table'
import type { EngineRef } from '@virtuoso.dev/data-table'

type StatusFilter = 'all' | 'active' | 'paused'

const rows = [
  { id: 'acct-1', account: 'Northwind', status: 'active' },
  { id: 'acct-2', account: 'Contoso', status: 'paused' },
  { id: 'acct-3', account: 'Fabrikam', status: 'active' },
]

function filterRows(data: typeof rows, status: StatusFilter) {
  return status === 'all' ? data : data.filter((row) => row.status === status)
}

function StatusControls({ engineRef }: { engineRef: EngineRef }) {
  const dispatch = useRemotePublisher(dispatchModelAction$, engineRef)
  const actionState = useRemoteCellValue(modelActionState$, engineRef)
  const selected = (actionState?.status?.payload ?? 'all') as StatusFilter

  const options: { value: StatusFilter; label: string }[] = [
    { value: 'all', label: 'All' },
    { value: 'active', label: 'Active' },
    { value: 'paused', label: 'Paused' },
  ]

  return (
    <div className="flex flex-wrap gap-2">
      {options.map((option) => (
        <Button
          aria-pressed={selected === option.value}
          key={option.value}
          onClick={() => dispatch({ action: 'status', payload: option.value })}
          variant={selected === option.value ? 'default' : 'outline'}
        >
          {option.label}
        </Button>
      ))}
    </div>
  )
}

export default function App() {
  const engineRef = useEngineRef()
  const [model] = useState(() =>
    localModel({
      data: rows,
      pipeline: ['status'],
      initialActions: [{ action: 'status', payload: 'all' }],
      actions: {
        status: {
          stage: 'status',
          handler: ({ data, payload }) => filterRows(data, payload as StatusFilter),
        },
      },
    })
  )

  return (
    <div className="space-y-4">
      <StatusControls engineRef={engineRef} />
      <DataTable className="rounded-xl" computeRowKey={({ data }) => data.id} engineRef={engineRef} model={model} style={{ height: 280 }}>
        <DataTableColumn field="account">
          <DataTableColumnHeader>Account</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="status">
          <DataTableColumnHeader>Status</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
        </DataTableColumn>
      </DataTable>
    </div>
  )
}

modelActionState$ tracks stateful model actions: the current filter, sort, group, search, or other action payload that represents a user choice. Command-like actions are different. refresh, cancel, loadMore, source mutators, and similar commands can be dispatched through dispatchModelAction$, but they are not remembered in modelActionState$ because they do not describe the current toolbar state.

The bridge blocks only model lifecycle names owned by the table itself: handshake, disconnect, and viewportChange.

The cells and streams most external UI reaches for, grouped by the hook they pair with.

Read with useRemoteCellValue:

  • columns$, visibleColumns$, columnWidths$ — column shape and layout
  • scrollLocation$, scrollerElement$, itemHeight$ — scroll state and DOM references

Publish with useRemotePublisher:

  • scrollToRow$, scrollIntoView$, cancelSmoothScroll$ — scroll commands
  • setColumnSticky$ — pin a column at runtime

Feature modules contribute their own: resizeColumn$ and friends from @virtuoso.dev/data-table/column-resize, reorderColumns$ from column-reorder, setColumnVisibility$ from column-visibility. Each feature page documents the cells and streams it adds.

Inside the table — in a cell renderer, in a custom row component — use the local hooks instead: useVirtuosoLocation(), useCurrentlyRenderedData(), useVirtuosoLoadingState(). They read the same underlying state but resolve the engine from React context, so no ref or ID is needed.

For DOM-level integrations, prefer scrollerElement$ and itemHeight$ over document.querySelector. The cells stay in sync with React’s mount and unmount; a query selector can return a node from a torn-down render or miss a remount entirely.

One-way notifications and first-render positioning live on the component, not the engine:

  • initialLocation — scrolls to a row when the table first renders with data
  • onScroll — receives the current ListScrollLocation
  • onRenderedDataChange — receives the rendered data rows, excluding group rows
<DataTable
  model={model}
  initialLocation={{ index: 80, align: 'center' }}
  onRenderedDataChange={(renderedRows) => {
    console.log(renderedRows.map((row) => row.id))
  }}
  onScroll={(location) => {
    console.log(location.scrollTop)
  }}
>
  {/* columns */}
</DataTable>

Use props when the parent only needs to observe — log scroll position, fire analytics, set an initial location once. Reach for the remote hooks when UI elsewhere in the tree needs to subscribe to the same updates or push commands back; props can’t carry a subscription out to a sibling component.

A pair of static-tuning props that, like the scroll lifecycle props above, live on the component rather than on the engine — overscan is a per-table policy, not something external UI flips at runtime. The defaults are good for typical row heights and cell complexity; raise them only when you can see the table struggling.

  • increaseViewportBy — extra vertical pixels rendered before and after the viewport. Raise when rows contain images that need to warm up before they scroll into view, or when each row’s render cost is high enough that the user can outrun the scheduler.
  • columnOverscanCount — extra columns rendered on each side of the horizontal viewport. Raise on wide tables that feel jumpy during horizontal scroll, especially when cells contain charts or non-trivial markup.

Both numbers cost render work even when the user isn’t scrolling, so bump them in small steps and stop as soon as the symptom is gone.