Skip to content

Local Filter, Sort, Group

A filter dropdown, a sort dropdown, and a grouping dropdown share one localModel pipeline. The pipeline runs filter → sort → group; each control publishes one action and only the affected stage (and the stages after it) rerun.

  • localModel()
  • pipeline to declare transformation order
  • stage to attach an action to a pipeline step
  • model.send() to publish filter, sort, and group changes
  • GroupHeaderCell
import { useEffect, useState } from 'react'

import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader, GroupHeaderCell } from '@/components/ui/data-table'
import { localModel } from '@virtuoso.dev/data-table'

import { FilterBar } from './FilterBar'
import { groupProducts, products } from './data'

import type { Product, ProductGroup } from './data'

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

export default function App() {
  const [category, setCategory] = useState('all')
  const [sortBy, setSortBy] = useState<'name' | 'price' | 'revenue'>('name')
  const [groupBy, setGroupBy] = useState<'none' | 'category' | 'status'>('category')

  const [model] = useState(() =>
    localModel<Product, ProductGroup>({
      data: products,
      pipeline: ['filter', 'sort', 'group'],
      actions: {
        filter: {
          stage: 'filter',
          handler: ({ data, payload }) => {
            const nextCategory = payload as string
            const rows = data.filter(isProduct)
            return nextCategory === 'all' ? rows : rows.filter((product) => product.category === nextCategory)
          },
        },
        sort: {
          stage: 'sort',
          handler: ({ data, payload }) => {
            const field = payload as 'name' | 'price' | 'revenue'
            return data.filter(isProduct).toSorted((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0))
          },
        },
        group: {
          stage: 'group',
          handler: ({ data, payload }) => groupProducts(data.filter(isProduct), payload as 'none' | 'category' | 'status'),
        },
      },
    })
  )

  useEffect(() => {
    model.send({ action: 'filter', payload: category })
  }, [category, model])

  useEffect(() => {
    model.send({ action: 'sort', payload: sortBy })
  }, [sortBy, model])

  useEffect(() => {
    model.send({ action: 'group', payload: groupBy })
  }, [groupBy, model])

  return (
    <div className="space-y-4">
      <FilterBar
        category={category}
        groupBy={groupBy}
        onCategoryChange={setCategory}
        onGroupByChange={setGroupBy}
        onSortByChange={setSortBy}
        sortBy={sortBy}
      />

      <DataTable className="rounded-xl" model={model} style={{ height: 420 }}>
        <GroupHeaderCell className="bg-muted px-3 py-2 font-medium">{({ row }) => row.data.label}</GroupHeaderCell>
        <DataTableColumn field="name">
          <DataTableColumnHeader>Product</DataTableColumnHeader>
          <DataTableCell className="font-medium">{({ row }) => row.data.name}</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 full dataset doesn’t fit in memory — switch to remoteModel and move filter/sort/group into request params.
  • Filtering rules need server-side evaluation (permissions, complex SQL, full-text search) — keep the table local for display but fetch filtered rows.