Skip to content

Grouped Rows

When records belong to natural sections — products by category, invoices by customer, employees by department — group headers split the scrolling row stream into visible groups. Users see one continuous list; the headers anchor to the top of each section as new rows scroll into view.

(This page is about row-level grouping. For grouping in the column header row, see column groups.)

The common case: a “Group by” control where the user picks the grouping at runtime. Click the buttons in this example to regroup the table:

import { useState } from 'react'

import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader, GroupHeaderCell } 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'

interface Product {
  id: string
  name: string
  category: 'Office' | 'Peripherals' | 'Audio'
  status: 'In stock' | 'Low stock'
}

interface ProductGroup {
  label: string
}

type GroupBy = 'none' | 'category' | 'status'

const products: Product[] = [
  { id: 'SKU-001', name: 'Standing Desk', category: 'Office', status: 'In stock' },
  { id: 'SKU-002', name: 'Desk Lamp', category: 'Office', status: 'Low stock' },
  { id: 'SKU-003', name: 'USB-C Dock', category: 'Peripherals', status: 'In stock' },
  { id: 'SKU-004', name: 'Mechanical Keyboard', category: 'Peripherals', status: 'In stock' },
  { id: 'SKU-005', name: 'Studio Monitor', category: 'Audio', status: 'Low stock' },
  { id: 'SKU-006', name: 'Audio Interface', category: 'Audio', status: 'In stock' },
]

function isProduct(row: Product | ProductGroup): row is Product {
  return 'id' in row
}

function rowKey(row: Product | ProductGroup) {
  return isProduct(row) ? row.id : `group-${row.label}`
}

function groupProducts(rows: Product[], groupBy: GroupBy) {
  if (groupBy === 'none') {
    return rows
  }

  const buckets = new Map<string, Product[]>()
  for (const row of rows) {
    const key = groupBy === 'category' ? row.category : row.status
    const bucket = buckets.get(key)
    if (bucket) {
      bucket.push(row)
    } else {
      buckets.set(key, [row])
    }
  }

  const data: (Product | ProductGroup)[] = []
  const groups: { index: number; level: number }[] = []

  for (const [key, items] of buckets) {
    groups.push({ index: data.length, level: 0 })
    data.push({ label: `${key} (${items.length})` })
    data.push(...items)
  }

  return { data, groups }
}

function GroupByControls({ engineRef }: { engineRef: EngineRef }) {
  const dispatch = useRemotePublisher(dispatchModelAction$, engineRef)
  const actionState = useRemoteCellValue(modelActionState$, engineRef)
  const groupBy = (actionState?.group?.payload ?? 'category') as GroupBy

  const options: { value: GroupBy; label: string }[] = [
    { value: 'none', label: 'No groups' },
    { value: 'category', label: 'Group by category' },
    { value: 'status', label: 'Group by status' },
  ]

  return (
    <div className="flex flex-wrap gap-2">
      {options.map((option) => (
        <button
          aria-pressed={groupBy === option.value}
          className={`rounded-md border px-3 py-1 text-sm ${groupBy === option.value ? 'bg-foreground text-background' : 'bg-background'}`}
          key={option.value}
          onClick={() => dispatch({ action: 'group', payload: option.value })}
        >
          {option.label}
        </button>
      ))}
    </div>
  )
}

export default function App() {
  const engineRef = useEngineRef()
  const [model] = useState(() =>
    localModel<Product, ProductGroup>({
      data: products,
      pipeline: ['group'],
      initialActions: [{ action: 'group', payload: 'category' }],
      actions: {
        group: {
          stage: 'group',
          handler: ({ data, payload }) => groupProducts(data.filter(isProduct), payload as GroupBy),
        },
      },
    })
  )

  return (
    <div className="space-y-3">
      <GroupByControls engineRef={engineRef} />
      <DataTable
        className="rounded-xl"
        computeRowKey={({ data }) => rowKey(data as Product | ProductGroup)}
        engineRef={engineRef}
        model={model}
        style={{ height: 320 }}
      >
        <GroupHeaderCell className="bg-muted/60 px-3 py-2 text-sm font-medium">
          {({ row }) => String((row.data as ProductGroup).label)}
        </GroupHeaderCell>
        <DataTableColumn field="name">
          <DataTableColumnHeader>Product</DataTableColumnHeader>
          <DataTableCell className="font-medium">{({ cellValue }) => String(cellValue ?? '')}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="category">
          <DataTableColumnHeader>Category</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue ?? '')}</DataTableCell>
        </DataTableColumn>
        <DataTableColumn field="status">
          <DataTableColumnHeader>Status</DataTableColumnHeader>
          <DataTableCell>{({ cellValue }) => String(cellValue ?? '')}</DataTableCell>
        </DataTableColumn>
      </DataTable>
    </div>
  )
}

The interactive grouping above does three things:

  • Declares a group pipeline stage on the model so the table knows where in its data pipeline regrouping runs. The pipeline lists the stages in order; ['group'] is the simplest case, but ['filter', 'sort', 'group'] is the typical production shape.
  • Defines a group action whose handler receives the current data and a payload (the user’s selection) and returns the new { data, groups } shape.
  • Connects the controls through the table engine. The buttons publish to dispatchModelAction$; the active button reads modelActionState$. The toolbar never stores a second copy of the grouping state.

The wire-up, isolated from the demo’s rendering code, looks like this:

const [model] = useState(() =>
  localModel<Product, ProductGroup>({
    data: products,
    pipeline: ['group'],
    initialActions: [{ action: 'group', payload: 'category' }],
    actions: {
      group: {
        stage: 'group',
        handler: ({ data, payload }) => groupProducts(data.filter(isProduct), payload as GroupBy),
      },
    },
  })
)

function GroupByControls({ engineRef }: { engineRef: EngineRef }) {
  const dispatch = useRemotePublisher(dispatchModelAction$, engineRef)
  const actionState = useRemoteCellValue(modelActionState$, engineRef)
  const groupBy = actionState?.group?.payload ?? 'category'

  return (
    <button aria-pressed={groupBy === 'category'} onClick={() => dispatch({ action: 'group', payload: 'category' })}>
      Group by category
    </button>
  )
}

For the full filter + sort + group combination — three controls sharing one pipeline — see Build an In-memory Data Pipeline for the runnable end-to-end recipe.

When the grouping is fixed at mount and won’t change at runtime, skip the pipeline action and pass the already-flattened result directly:

const tableData = {
  data: [
    { label: 'Office (2)' },
    { id: 'SKU-001', name: 'Standing Desk', category: 'Office' },
    { id: 'SKU-002', name: 'Desk Lamp', category: 'Office' },
  ],
  groups: [{ index: 0, level: 0 }],
}

const model = localModel(tableData)

GroupHeaderCell receives row.data for the marked entry, so it can read whatever fields you put on the group object (here, label).

The table doesn’t accept nested children. It receives one flattened row array plus markers identifying which entries are group headers:

type GroupedRows<TData, TGroup> = {
  data: Array<TData | TGroup>
  groups: { index: number; level: number }[]
}
  • data mixes group rows and data rows in display order.
  • groups[] marks which positions in data are headers. Each marker carries an index (zero-based position in the flattened array) and a level (nesting depth).
  • The table renders marked entries through GroupHeaderCell instead of through column cells.

Group rows only need the fields your GroupHeaderCell reads. Data rows need the fields your columns read. The two shapes can differ; the table picks the renderer per entry based on the markers.

For deeper hierarchy (region → category → product), use sequential markers with increasing levels:

const groups = [
  { index: 0, level: 0 }, // 'North America'
  { index: 1, level: 1 }, //   'Office'
  { index: 4, level: 1 }, //   'Peripherals'
  { index: 7, level: 0 }, // 'Europe'
  { index: 8, level: 1 }, //   'Office'
]

Hierarchy is implicit in row order: every level-1 marker belongs to the most recent level-0 marker that precedes it. Style nesting visually by branching on row.level inside GroupHeaderCell:

<GroupHeaderCell>
  {({ row }) => (
    <div className={row.level === 0 ? 'bg-muted font-medium' : 'bg-muted/40 pl-6'}>{(row.data as { label: string }).label}</div>
  )}
</GroupHeaderCell>

level controls visual and sticky nesting only — it does not add parent-child data behavior. If you need to know which level-0 group a row belongs to, derive it from row order at the time you flatten.

When the grouping is computed on the backend, the fetch result includes markers alongside the rows; remoteModel forwards them to the table. The backend returns a flattened grouped result, not nested children:

async function fetchProducts(params: FetchParams<Query>) {
  const result = await fetchGroupedRows(params)

  return {
    rows: result.rows,
    groups: result.groups,
    totalCount: result.totalCount,
  }
}

In offset mode, totalCount is the length of the full flattened result (including group headers), and marker indexes are positions in that full result — not relative to the current page. In append mode, return markers for the row stream after each append. Group rows still need a shape your GroupHeaderCell can branch on.

  • Group rows are virtualized full-width rows — keep their content lightweight.
  • Give group rows stable keys via computeRowKey when grouping, sorting, or filtering can reorder them. Use a stable group identifier (groupKey, region, etc.), not the current group index.
  • The table doesn’t compute groups for you. Either group your data before passing it in, or compute inside a pipeline stage.
  • level controls visual and sticky nesting. It does not add parent-child data behavior.
  • Nested source data. Flatten it before passing to the table — the table’s display contract is flat-with-markers, not nested children.
  • User-toggleable expand/collapse. Keep the expanded/collapsed state in your app and rebuild the flattened data and groups from it on each toggle.
  • Remote grouping across unloaded ranges. Compute the grouping on the backend, or return enough metadata for the client to place group headers correctly without seeing every row.

See Group Rows by Region and Category for a complete pre-grouped local table, and Build an In-memory Data Pipeline for grouping wired alongside filter and sort.