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-buttonThe registry item name is hyphenated; it installs the nested import path @/components/ui/data-table/column-sort.
APIs used
Section titled “APIs used”remoteModel()— refetches rows when request params changeSortHeaderButton— shadcn slot component for column-header sortingHeaderEnd— places the sort button after the header labelcomputeRowKey— keeps row identity stable when sorting reorders rowsinitialActions— seeds the active sort in model action statemodelStatePersistenceAdapter()— 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>
)
}When this doesn’t fit
Section titled “When this doesn’t fit”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.