Skip to content

Remote Column Sorting

Each sortable column header sends a sort action to the remote model. The action updates the request params, the model refetches from the first page, and the active header button reads the current sort from modelActionState$. Do not keep a second sortBy / sortOrder React state just to paint the header; seed the model with initialActions for a default sort and let modelActionState$ be the source of truth.

The shadcn SortHeaderButton defaults to a payload shaped as { field, direction }, where direction cycles through 'asc', 'desc', and no sort.

Install the optional sort button wrapper before importing it:

npx shadcn@latest add petyosi/react-virtuoso/data-table-sort-header-button

The registry item name is hyphenated; it installs the nested import path @/components/ui/data-table/column-sort.

  • remoteModel() — refetches rows when request params change
  • SortHeaderButton — shadcn slot component for column-header sorting
  • HeaderEnd — places the sort button after the header label
  • computeRowKey — keeps row identity stable when sorting reorders rows
  • initialActions — seeds the active sort in model action state
  • modelStatePersistenceAdapter() — optionally persists the sort action payload across reloads
import { useState } from 'react'

import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader, HeaderEnd } from '@/components/ui/data-table'
import { SortHeaderButton } from '@/components/ui/data-table/column-sort'
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 defaultSort = { field: 'name', direction: 'asc' } satisfies NonNullable<Query['sort']>
const persistenceAdapters = [modelStatePersistenceAdapter()]

export default function App() {
  const [model] = useState(() =>
    remoteModel<Product, Query>({
      actions: {
        sort: {
          persistence: true,
          strategy: 'supersede',
          handler: ({ params, payload }) => ({ ...params, sort: payload as Query['sort'] }),
        },
      },
      fetch: fetchProducts,
      initialActions: [{ action: 'sort', payload: defaultSort }],
      initialParams: { sort: defaultSort },
      onViewportChange: defaultOffsetViewportHandler,
      pageSize: 40,
      placeholder,
    })
  )

  return (
    <DataTable className="rounded-xl" computeRowKey={({ data }) => data.id} model={model} style={{ height: 420 }}>
      <DataTableStatePersistence adapters={persistenceAdapters} storageKey="remote-column-sorting-example" />

      <DataTableColumn field="name">
        <DataTableColumnHeader>
          <HeaderEnd component={SortHeaderButton} />
          {() => 'Product'}
        </DataTableColumnHeader>
        <DataTableCell className="font-medium">{({ row }) => row.data.name}</DataTableCell>
      </DataTableColumn>
      <DataTableColumn field="category">
        <DataTableColumnHeader>
          <HeaderEnd component={SortHeaderButton} />
          {() => 'Category'}
        </DataTableColumnHeader>
        <DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
      </DataTableColumn>
      <DataTableColumn field="price">
        <DataTableColumnHeader className="justify-end">
          <HeaderEnd component={SortHeaderButton} />
          {() => 'Price'}
        </DataTableColumnHeader>
        <DataTableCell className="text-right tabular-nums">{({ cellValue }) => `$${cellValue}`}</DataTableCell>
      </DataTableColumn>
    </DataTable>
  )
}

If your API uses a different query shape, render the button manually in HeaderEnd and pass action, field, getDirection, or getPayload props to SortHeaderButton. Copy the installed component into your app-specific table wrapper when every table in the app should share the same sort payload shape.