Server-side Data
Pagination, sorting and search on the server, fetched with TanStack Query
Overview
When the server does the work, the table only needs to render the page it gets back:
- Own the
pagination,sortingandglobalFilterstate yourself. - Put that state in the query key and send it to your API.
- Pass
manualPagination,manualSortingandmanualFiltering, plus the server'srowCount.
placeholderData: keepPreviousData keeps the current page on screen while the next one loads. isFetching shows a spinner over it in the meantime.
Demo
This demo calls /api/users, which paginates, sorts and searches 200 generated users with a simulated 300ms delay.
Rows per page
Page 1 of 1
Code
tsx
'use client'
import {
QueryClient,
QueryClientProvider,
keepPreviousData,
useQuery,
} from '@tanstack/react-query'
import type { PaginationState, SortingState } from '@tanstack/react-table'
import { useDeferredValue, useState } from 'react'
import { DataTable } from '@/components/shadcntable/data-table'
import { DataTableColumnHeader } from '@/components/shadcntable/data-table-column-header'
import { DataTablePagination } from '@/components/shadcntable/data-table-pagination'
import { DataTableToolbar } from '@/components/shadcntable/data-table-toolbar'
import { createDataTableColumnHelper } from '@/components/shadcntable/lib/features'
import { useDataTable } from '@/components/shadcntable/use-data-table'
type Person = { firstName: string; lastName: string; email: string }
type UsersResponse = { rows: Person[]; rowCount: number }
const EMPTY_ROWS: Person[] = []
const columnHelper = createDataTableColumnHelper<Person>()
const columns = columnHelper.columns([
columnHelper.accessor('firstName', {
header: ({ column }) => <DataTableColumnHeader column={column} />,
meta: { label: 'First name' },
}),
columnHelper.accessor('lastName', {
header: ({ column }) => <DataTableColumnHeader column={column} />,
meta: { label: 'Last name' },
}),
columnHelper.accessor('email', {
header: ({ column }) => <DataTableColumnHeader column={column} />,
meta: { label: 'Email' },
}),
])
async function fetchUsers(
pagination: PaginationState,
sorting: SortingState,
search: string,
): Promise<UsersResponse> {
const params = new URLSearchParams({
page: String(pagination.pageIndex + 1),
pageSize: String(pagination.pageSize),
})
if (search) params.set('search', search)
if (sorting[0]) {
params.set('sort', sorting[0].id)
params.set('desc', String(sorting[0].desc))
}
const response = await fetch(`/api/users?${params.toString()}`)
if (!response.ok) throw new Error('Failed to fetch users')
return (await response.json()) as UsersResponse
}
export function ServerSideDemo() {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
<ServerSideTable />
</QueryClientProvider>
)
}
function ServerSideTable() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState('')
const search = useDeferredValue(globalFilter)
const { data, isLoading, isFetching } = useQuery({
queryKey: ['users', pagination, sorting, search],
queryFn: () => fetchUsers(pagination, sorting, search),
placeholderData: keepPreviousData,
})
const table = useDataTable({
columns,
data: data?.rows ?? EMPTY_ROWS,
rowCount: data?.rowCount,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
state: { pagination, sorting, globalFilter },
onPaginationChange: setPagination,
onSortingChange: setSorting,
onGlobalFilterChange: (updater) => {
setGlobalFilter(updater)
setPagination((previous) => ({ ...previous, pageIndex: 0 }))
},
})
return (
<div className='space-y-4'>
<DataTableToolbar table={table} />
<DataTable table={table} isLoading={isLoading} isFetching={isFetching} />
<DataTablePagination table={table} />
</div>
)
}Notes
- Reset
pageIndexwhen the search changes, or the user can end up past the last page of results. useDeferredValuekeeps typing responsive without a debounce. Swap in a debounce to send fewer requests.- Keep the empty fallback (
EMPTY_ROWS) at module scope. A new[]on every render would make the table recompute its rows.