Server-Side Table
Server-side pagination, sorting, and filtering with TanStack Query for efficient data fetching and caching.
View Full State Object
{
"totalCount": 0,
"currentPageItems": 0,
"page": 1,
"pageSize": 10
}No enhanced filters
{
"totalFilters": 0,
"hasAndFilters": false,
"hasOrFilters": false,
"effectiveJoinOperator": "and",
"activeFilters": 0,
"currentMode": "standard"
}[]
[]
""
{
"pageIndex": 0,
"pageSize": 10
}{}💡 Tip: This example uses TanStack Query for server-side data fetching with automatic caching. State is managed with React useState and does NOT persist across page refreshes. For URL state persistence, see the Server-Side Nuqs Table example.
"use client"
/** * Server-Side Data Table Example with TanStack Query * * This example demonstrates server-side pagination, sorting, and filtering * using TanStack Query for efficient data fetching, caching, and state management. * This example does NOT use URL state persistence - state is managed with React useState. * * Prerequisites: * 1. Install TanStack Query: * npm install @tanstack/react-query * * IMPORTANT SETUP REQUIRED: * This component includes QueryClientProvider at the component level * for a complete, self-contained example. For production apps, it's recommended to add * this provider at your root layout instead: * * For Next.js App Router: * 1. Wrap your app with QueryClientProvider in app/layout.tsx: * ```tsx * import { QueryClient, QueryClientProvider } from '@tanstack/react-query' * * const queryClient = new QueryClient({ * defaultOptions: { * queries: { * staleTime: 60 * 1000, // 1 minute * refetchOnWindowFocus: false, * }, * }, * }) * * export default function RootLayout({ children }) { * return ( * <html> * <body> * <QueryClientProvider client={queryClient}> * {children} * </QueryClientProvider> * </body> * </html> * ) * } * ``` * * For Next.js Pages Router: * 1. Wrap your app with QueryClientProvider in pages/_app.tsx: * ```tsx * import { QueryClient, QueryClientProvider } from '@tanstack/react-query' * * const queryClient = new QueryClient({ * defaultOptions: { * queries: { * staleTime: 60 * 1000, * refetchOnWindowFocus: false, * }, * }, * }) * * export default function App({ Component, pageProps }) { * return ( * <QueryClientProvider client={queryClient}> * <Component {...pageProps} /> * </QueryClientProvider> * ) * } * ``` * * For React SPA (Vite, CRA, etc.): * 1. Wrap your app with QueryClientProvider in src/main.tsx: * ```tsx * import { QueryClient, QueryClientProvider } from '@tanstack/react-query' * * const queryClient = new QueryClient({ * defaultOptions: { * queries: { * staleTime: 60 * 1000, * refetchOnWindowFocus: false, * }, * }, * }) * * createRoot(document.getElementById('root')!).render( * <QueryClientProvider client={queryClient}> * <App /> * </QueryClientProvider> * ) * ``` * * Features: * - Automatic caching and refetching (TanStack Query) * - Background updates * - Request deduplication * - Loading and error states * - Server-side pagination, sorting, and filtering * - Optimistic updates support * - Query invalidation support * - keepPreviousData for smooth pagination * * Learn more: * - TanStack Query: https://tanstack.com/query/latest */
import { useState, useCallback, useMemo, useRef, useEffect, startTransition,} from "react"import { useQuery, useQueryClient, QueryClient, QueryClientProvider, keepPreviousData,} from "@tanstack/react-query"import type { PaginationState, SortingState, ColumnFiltersState, VisibilityState, Updater,} from "@tanstack/react-table"import { DataTableRoot, DataTable, DataTableHeader, DataTableBody, DataTableEmptyBody, DataTableSkeleton,} from "@/components/niko-table/core"import { DataTableToolbarSection, DataTablePagination, DataTableSearchFilter, DataTableViewMenu, DataTableSortMenu, DataTableFilterMenu, DataTableInlineFilter, DataTableEmptyFilteredMessage, DataTableEmptyTitle, DataTableEmptyDescription, DataTableEmptyIcon, DataTableEmptyMessage, DataTableColumnTitle, DataTableColumnHeader, DataTableColumnSortMenu, DataTableColumnFacetedFilterMenu, DataTableColumnSliderFilterMenu, DataTableColumnDateFilterMenu,} from "@/components/niko-table/components"import { daysAgo, JOIN_OPERATORS, FILTER_OPERATORS, processFiltersForLogic, FILTER_VARIANTS,} from "@/components/niko-table/lib"import type { DataTableColumnDef, ExtendedColumnFilter,} from "@/components/niko-table/types"import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card"import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"import { Loader2, AlertCircle, UserSearch, SearchX } from "lucide-react"
type Product = { id: string name: string category: string brand: string price: number stock: number rating: number inStock: boolean releaseDate: Date}
// Static product data - same as other examples for consistency// In a real app, this would be fetched from a serverconst initialData: Product[] = [ { id: "1", name: "iPhone 15 Pro", category: "electronics", brand: "apple", price: 999, stock: 45, rating: 5, inStock: true, releaseDate: daysAgo(5), }, { id: "2", name: "Galaxy S24 Ultra", category: "electronics", brand: "samsung", price: 1199, stock: 32, rating: 5, inStock: true, releaseDate: daysAgo(10), }, { id: "3", name: "Air Jordan 1", category: "sports", brand: "nike", price: 170, stock: 8, rating: 4, inStock: true, releaseDate: daysAgo(25), }, { id: "4", name: "Ultraboost 23", category: "sports", brand: "adidas", price: 190, stock: 15, rating: 4, inStock: true, releaseDate: daysAgo(50), }, { id: "5", name: "PlayStation 5", category: "electronics", brand: "sony", price: 499, stock: 0, rating: 5, inStock: false, releaseDate: daysAgo(365), }, { id: "6", name: "OLED C3 TV", category: "electronics", brand: "lg", price: 1499, stock: 12, rating: 5, inStock: true, releaseDate: daysAgo(90), }, { id: "7", name: "XPS 15 Laptop", category: "electronics", brand: "dell", price: 1899, stock: 20, rating: 4, inStock: true, releaseDate: daysAgo(120), }, { id: "8", name: "Spectre x360", category: "electronics", brand: "hp", price: 1599, stock: 18, rating: 4, inStock: true, releaseDate: daysAgo(15), }, { id: "9", name: "MacBook Pro 16", category: "electronics", brand: "apple", price: 2499, stock: 25, rating: 5, inStock: true, releaseDate: daysAgo(30), }, { id: "10", name: "Galaxy Book3", category: "electronics", brand: "samsung", price: 1399, stock: 14, rating: 4, inStock: true, releaseDate: daysAgo(180), }, { id: "11", name: "Running Shorts", category: "clothing", brand: "nike", price: 45, stock: 120, rating: 3, inStock: true, releaseDate: daysAgo(60), }, { id: "12", name: "Training Jacket", category: "clothing", brand: "adidas", price: 85, stock: 65, rating: 4, inStock: true, releaseDate: daysAgo(45), }, { id: "13", name: "Garden Tools Set", category: "home-garden", brand: "hp", price: 120, stock: 30, rating: 4, inStock: true, releaseDate: daysAgo(75), }, { id: "14", name: "Programming Book", category: "books", brand: "dell", price: 60, stock: 50, rating: 5, inStock: true, releaseDate: daysAgo(200), }, { id: "15", name: "Wireless Mouse", category: "electronics", brand: "lg", price: 35, stock: 200, rating: 3, inStock: true, releaseDate: daysAgo(150), },]
// Generate larger dataset by duplicating and varying the initial data// In a real app, this would come from a server APIconst generateMockProducts = (count: number): Product[] => { const result: Product[] = [] const baseCount = initialData.length
for (let i = 0; i < count; i++) { const baseProduct = initialData[i % baseCount] const variation = Math.floor(i / baseCount)
result.push({ ...baseProduct, id: `${baseProduct.id}-${variation}`, name: variation > 0 ? `${baseProduct.name} (${variation + 1})` : baseProduct.name, price: baseProduct.price + variation * 10, stock: Math.max(0, baseProduct.stock - variation * 5), rating: baseProduct.rating, inStock: baseProduct.stock - variation * 5 > 0, releaseDate: new Date( baseProduct.releaseDate.getTime() - variation * 24 * 60 * 60 * 1000, ), }) }
return result}
// Server-side API simulationtype ServerResponse<T> = { data: T[] total: number page: number pageSize: number}
type FetchParams = { page: number pageSize: number sorting: SortingState globalFilter: string | object columnFilters: ColumnFiltersState}
// Simulate server-side filtering, sorting, and paginationfunction fetchProducts( params: FetchParams, delay = 800,): Promise<ServerResponse<Product>> { return new Promise((resolve, reject) => { setTimeout(() => { try { // Simulate occasional errors (5% chance) if (Math.random() < 0.05) { reject(new Error("Server error: Failed to fetch products")) return }
// Generate a large dataset const allProducts = generateMockProducts(500)
// Apply server-side filtering let filtered = [...allProducts]
// Helper function to check if a product matches a single filter const matchesFilter = ( product: Product, filter: ExtendedColumnFilter<Product>, ): boolean => { const productValue = product[filter.id as keyof Product] const filterValue = filter.value
// Handle empty value filters if ( filter.operator === FILTER_OPERATORS.EMPTY || filter.operator === FILTER_OPERATORS.NOT_EMPTY ) { // These don't need a value } else if (!filterValue || filterValue === "") { return true // Empty filter value means no filter }
switch (filter.operator) { case FILTER_OPERATORS.EQ: return ( String(productValue).toLowerCase() === String(filterValue).toLowerCase() ) case FILTER_OPERATORS.NEQ: return ( String(productValue).toLowerCase() !== String(filterValue).toLowerCase() ) case FILTER_OPERATORS.ILIKE: return String(productValue) .toLowerCase() .includes(String(filterValue).toLowerCase()) case FILTER_OPERATORS.NOT_ILIKE: return !String(productValue) .toLowerCase() .includes(String(filterValue).toLowerCase()) case FILTER_OPERATORS.GT: return Number(productValue) > Number(filterValue) case FILTER_OPERATORS.LT: return Number(productValue) < Number(filterValue) case FILTER_OPERATORS.GTE: return Number(productValue) >= Number(filterValue) case FILTER_OPERATORS.LTE: return Number(productValue) <= Number(filterValue) case FILTER_OPERATORS.EMPTY: return ( productValue === null || productValue === undefined || String(productValue).trim() === "" ) case FILTER_OPERATORS.NOT_EMPTY: return ( productValue !== null && productValue !== undefined && String(productValue).trim() !== "" ) case FILTER_OPERATORS.IN: if (Array.isArray(filterValue)) { return filterValue.some( v => String(productValue).toLowerCase() === String(v).toLowerCase(), ) } return false case FILTER_OPERATORS.NOT_IN: if (Array.isArray(filterValue)) { return !filterValue.some( v => String(productValue).toLowerCase() === String(v).toLowerCase(), ) } return true default: return true } }
// Apply global search filter (server-side) - string search if (typeof params.globalFilter === "string" && params.globalFilter) { const searchTerm = params.globalFilter.toLowerCase() filtered = filtered.filter(product => Object.values(product).some(value => String(value).toLowerCase().includes(searchTerm), ), ) }
// Apply OR filters from globalFilter (when it's an object with filters) if ( typeof params.globalFilter === "object" && params.globalFilter && "filters" in params.globalFilter ) { const filterObj = params.globalFilter as { filters: ExtendedColumnFilter<Product>[] joinOperator: string } const orFilters = filterObj.filters || []
if (orFilters.length > 0) { // Filter out empty filters const validFilters = orFilters.filter( f => f.value && f.value !== "", )
if (validFilters.length > 0) { // Apply OR logic: product matches if ANY filter matches filtered = filtered.filter(product => { return validFilters.some(filter => matchesFilter(product, filter), ) }) } } }
// Apply AND filters from columnFilters (server-side) if (params.columnFilters.length > 0) { filtered = filtered.filter(product => { return params.columnFilters.every(filter => { const value = filter.value
// Skip empty filters if ( !value || (typeof value === "object" && "value" in value && !value.value) ) { return true }
// Handle ExtendedColumnFilter format if (typeof value === "object" && "id" in value) { const extendedFilter = value as ExtendedColumnFilter<Product> return matchesFilter(product, extendedFilter) }
return true }) }) }
// Apply server-side sorting if (params.sorting.length > 0) { filtered.sort((a, b) => { for (const sort of params.sorting) { const aValue = a[sort.id as keyof Product] const bValue = b[sort.id as keyof Product]
if (aValue === bValue) continue
const comparison = aValue < bValue ? -1 : aValue > bValue ? 1 : 0
return sort.desc ? -comparison : comparison } return 0 }) }
const total = filtered.length const start = params.page * params.pageSize const end = start + params.pageSize const paginated = filtered.slice(start, end)
resolve({ data: paginated, total, page: params.page, pageSize: params.pageSize, }) } catch (error) { reject(error) } }, delay) })}
const categoryOptions = [ { label: "Electronics", value: "electronics" }, { label: "Clothing", value: "clothing" }, { label: "Home & Garden", value: "home-garden" }, { label: "Sports", value: "sports" }, { label: "Books", value: "books" },]
const brandOptions = [ { label: "Apple", value: "apple" }, { label: "Samsung", value: "samsung" }, { label: "Nike", value: "nike" }, { label: "Adidas", value: "adidas" }, { label: "Sony", value: "sony" }, { label: "LG", value: "lg" }, { label: "Dell", value: "dell" }, { label: "HP", value: "hp" },]
const columns: DataTableColumnDef<Product>[] = [ { accessorKey: "name", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), meta: { label: "Product Name", variant: FILTER_VARIANTS.TEXT, }, enableColumnFilter: true, }, { accessorKey: "category", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} /> <DataTableColumnFacetedFilterMenu /> </DataTableColumnHeader> ), meta: { label: "Category", variant: FILTER_VARIANTS.SELECT, options: categoryOptions, }, cell: ({ row }) => { const category = row.getValue("category") as string const option = categoryOptions.find(opt => opt.value === category) return <span>{option?.label || category}</span> }, enableColumnFilter: true, }, { accessorKey: "brand", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} /> <DataTableColumnFacetedFilterMenu /> </DataTableColumnHeader> ), meta: { label: "Brand", variant: FILTER_VARIANTS.SELECT, options: brandOptions, }, enableColumnFilter: true, }, { accessorKey: "price", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> <DataTableColumnSliderFilterMenu /> </DataTableColumnHeader> ), meta: { label: "Price", unit: "$", variant: FILTER_VARIANTS.NUMBER, }, cell: ({ row }) => { const price = parseFloat(row.getValue("price")) return <div className="font-medium">${price.toFixed(2)}</div> }, enableColumnFilter: true, }, { accessorKey: "stock", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </DataTableColumnHeader> ), meta: { label: "Stock", variant: FILTER_VARIANTS.NUMBER, }, cell: ({ row }) => { const stock = Number(row.getValue("stock")) return ( <div className={stock < 10 ? "font-medium text-red-600" : ""}> {stock} </div> ) }, enableColumnFilter: true, }, { accessorKey: "rating", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </DataTableColumnHeader> ), meta: { label: "Rating", variant: FILTER_VARIANTS.NUMBER, }, cell: ({ row }) => { const rating = Number(row.getValue("rating")) return ( <div className="flex items-center gap-1"> <span>{rating}</span> <span className="text-yellow-500">★</span> </div> ) }, enableColumnFilter: true, }, { accessorKey: "inStock", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle /> <DataTableColumnSortMenu /> <DataTableColumnFacetedFilterMenu /> </DataTableColumnHeader> ), meta: { label: "In Stock", variant: FILTER_VARIANTS.BOOLEAN, }, cell: ({ row }) => { const inStock = Boolean(row.getValue("inStock")) return ( <Badge variant={inStock ? "default" : "secondary"}> {inStock ? "Yes" : "No"} </Badge> ) }, enableColumnFilter: true, }, { accessorKey: "releaseDate", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle /> <DataTableColumnSortMenu /> <DataTableColumnDateFilterMenu /> </DataTableColumnHeader> ), meta: { label: "Release Date", variant: FILTER_VARIANTS.DATE, }, cell: ({ row }) => { const date = row.getValue("releaseDate") as Date return <span>{date.toLocaleDateString()}</span> }, enableColumnFilter: true, },]
function StandardFilterToolbar({ filters, onFiltersChange,}: { filters: ExtendedColumnFilter<Product>[] onFiltersChange: (filters: ExtendedColumnFilter<Product>[] | null) => void}) { return ( <DataTableToolbarSection> <DataTableToolbarSection className="px-0"> <DataTableSearchFilter placeholder="Search products..." /> <DataTableViewMenu /> </DataTableToolbarSection> <DataTableToolbarSection className="px-0"> <DataTableSortMenu className="ml-auto" /> <DataTableFilterMenu filters={filters} onFiltersChange={onFiltersChange} /> </DataTableToolbarSection> </DataTableToolbarSection> )}
function InlineFilterToolbar({ filters, onFiltersChange,}: { filters: ExtendedColumnFilter<Product>[] onFiltersChange: (filters: ExtendedColumnFilter<Product>[]) => void}) { return ( <DataTableToolbarSection> <DataTableToolbarSection className="px-0"> <DataTableSearchFilter placeholder="Search products..." /> <DataTableViewMenu /> </DataTableToolbarSection> <DataTableToolbarSection className="px-0"> <DataTableInlineFilter filters={filters} onFiltersChange={onFiltersChange} /> </DataTableToolbarSection> </DataTableToolbarSection> )}
/** * Normalize filters to ensure they have unique filterIds * This is critical when loading filters from URL, as they may not have filterIds * or may have duplicate IDs when multiple filters share the same column * * IMPORTANT: This function preserves filter object references when possible * to prevent unnecessary re-renders and focus loss in input fields. */function normalizeFiltersWithUniqueIds<TData>( filters: ( | Omit<ExtendedColumnFilter<TData>, "filterId"> | ExtendedColumnFilter<TData> )[],): ExtendedColumnFilter<TData>[] { // Quick check: if all filters already have unique filterIds, return as-is // This preserves object references and prevents unnecessary re-renders const hasAllIds = filters.every( (f): f is ExtendedColumnFilter<TData> => "filterId" in f && !!f.filterId, ) if (hasAllIds) { const ids = new Set( filters.map(f => (f as ExtendedColumnFilter<TData>).filterId), ) // If all IDs are unique, return filters unchanged (preserve references) if (ids.size === filters.length) { return filters as ExtendedColumnFilter<TData>[] } }
// Need to normalize - some filters missing IDs or have duplicates const seenIds = new Set<string>()
return filters.map((filter, index) => { // If filter already has a filterId, check if it's unique if ("filterId" in filter && filter.filterId) { // If this ID was already seen, regenerate it to ensure uniqueness if (seenIds.has(filter.filterId)) { // Generate a new unique ID based on index (not value) to keep it stable const uniqueId = `filter-${filter.id}-${index}-dup${seenIds.size}` .toLowerCase() .replace(/[^a-z0-9-]/g, "-") .replace(/-+/g, "-") .substring(0, 100)
seenIds.add(uniqueId) return { ...filter, filterId: uniqueId, } as ExtendedColumnFilter<TData> }
// ID is unique, preserve it (and the filter object reference) seenIds.add(filter.filterId) return filter as ExtendedColumnFilter<TData> }
// Filter doesn't have a filterId, generate one // IMPORTANT: Use index as the primary uniqueness factor, not value // This ensures filterId stays stable when only the value changes, // preventing React from treating it as a new filter and losing focus const uniqueId = `filter-${filter.id}-${index}` .toLowerCase() .replace(/[^a-z0-9-]/g, "-") .replace(/-+/g, "-") .substring(0, 100)
// Ensure the generated ID is unique (in case of collisions) let finalId = uniqueId let counter = 0 while (seenIds.has(finalId)) { finalId = `${uniqueId}-${counter}` counter++ }
seenIds.add(finalId) return { ...filter, filterId: finalId, } as ExtendedColumnFilter<TData> })}
function ServerSideStateTableContent() { // State management with useState - no URL persistence const [filterMode, setFilterMode] = useState<"standard" | "inline">( "standard", ) const [globalFilter, setGlobalFilter] = useState<string | object>("") const [sorting, setSorting] = useState<SortingState>([]) const [standardColumnFilters, setStandardColumnFilters] = useState<ColumnFiltersState>([]) const [inlineColumnFilters, setInlineColumnFilters] = useState<ColumnFiltersState>([]) const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}) const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10, })
// Get active column filters based on filter mode const columnFilters = filterMode === "standard" ? standardColumnFilters : inlineColumnFilters
// Use TanStack Query for server-side data fetching // This provides automatic caching, refetching, and error handling // Using placeholderData: keepPreviousData prevents UI jumps during pagination // by keeping the previous data visible while new data is being fetched const { data: queryData, isLoading, error: queryError, isFetching, isPlaceholderData, refetch, } = useQuery({ queryKey: [ "products", pagination.pageIndex, pagination.pageSize, sorting, globalFilter, columnFilters, filterMode, ], queryFn: () => fetchProducts({ page: pagination.pageIndex, pageSize: pagination.pageSize, sorting, globalFilter, columnFilters, }), staleTime: 30000, // Consider data fresh for 30 seconds gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes placeholderData: keepPreviousData, // Keep previous data visible during pagination })
// Extract data and total from query response const data = queryData?.data ?? [] const totalCount = queryData?.total ?? 0 const error = queryError ? queryError instanceof Error ? queryError.message : "Failed to fetch data" : null
// Get query client for prefetching const queryClient = useQueryClient()
// Track prefetching state const [prefetchingState, setPrefetchingState] = useState<{ next: boolean previous: boolean }>({ next: false, previous: false }) const effectRunRef = useRef(0) const abortControllerRef = useRef<AbortController | null>(null)
// Prefetch next and previous pages for smoother navigation useEffect(() => { const totalPages = Math.ceil(totalCount / pagination.pageSize) const currentPage = pagination.pageIndex const currentRun = ++effectRunRef.current
// Abort any previous prefetch operations if (abortControllerRef.current) { abortControllerRef.current.abort() }
// Create new abort controller for this effect run const abortController = new AbortController() abortControllerRef.current = abortController
// Reset prefetching state immediately when effect runs // Use startTransition to mark as non-urgent and avoid cascading renders startTransition(() => { setPrefetchingState({ next: false, previous: false }) })
// Helper to safely update state only if this effect run is still current const safeSetState = ( updater: (prev: { next: boolean; previous: boolean }) => { next: boolean previous: boolean }, ) => { if ( currentRun === effectRunRef.current && !abortController.signal.aborted ) { setPrefetchingState(updater) } }
// Prefetch next page if it exists if (currentPage + 1 < totalPages && !abortController.signal.aborted) { safeSetState(prev => ({ ...prev, next: true }))
queryClient .prefetchQuery({ queryKey: [ "products", currentPage + 1, pagination.pageSize, sorting, globalFilter, columnFilters, filterMode, ], queryFn: () => fetchProducts({ page: currentPage + 1, pageSize: pagination.pageSize, sorting, globalFilter, columnFilters, }), staleTime: 30000, }) .then(() => { safeSetState(prev => ({ ...prev, next: false })) }) .catch(() => { // Reset on error too safeSetState(prev => ({ ...prev, next: false })) }) }
// Prefetch previous page if it exists if (currentPage > 0 && !abortController.signal.aborted) { safeSetState(prev => ({ ...prev, previous: true }))
queryClient .prefetchQuery({ queryKey: [ "products", currentPage - 1, pagination.pageSize, sorting, globalFilter, columnFilters, filterMode, ], queryFn: () => fetchProducts({ page: currentPage - 1, pageSize: pagination.pageSize, sorting, globalFilter, columnFilters, }), staleTime: 30000, }) .then(() => { safeSetState(prev => ({ ...prev, previous: false })) }) .catch(() => { // Reset on error too safeSetState(prev => ({ ...prev, previous: false })) }) }
// Cleanup function: reset state and abort operations when effect re-runs return () => { // Reset state immediately (cleanup is safe to do synchronously) setPrefetchingState({ next: false, previous: false }) // Abort any ongoing prefetch operations if (abortControllerRef.current) { abortControllerRef.current.abort() abortControllerRef.current = null } // Note: Don't reset effectRunRef.current here - it's already been incremented // by the new effect run. Setting it to 0 would break the safeSetState checks. } }, [ queryClient, pagination.pageIndex, pagination.pageSize, totalCount, sorting, globalFilter, columnFilters, filterMode, ])
const resetAllState = useCallback(() => { setGlobalFilter("") setSorting([]) setStandardColumnFilters([]) setInlineColumnFilters([]) setColumnVisibility({}) setPagination({ pageIndex: 0, pageSize: 10 }) setFilterMode("standard") }, [])
// Helper to display global filter state const getGlobalFilterDisplay = () => { if (typeof globalFilter === "string") { return globalFilter || "None" } if ( typeof globalFilter === "object" && globalFilter && "filters" in globalFilter ) { const filterObj = globalFilter as { filters: unknown[] joinOperator: string } return `OR Filter (${filterObj.filters?.length || 0} conditions)` } return "None" }
// Extract actual filter data for display const displayFilters = useMemo(() => { if ( typeof globalFilter === "object" && globalFilter && "filters" in globalFilter ) { const filterObj = globalFilter as { filters: unknown[] joinOperator: string } return filterObj.filters || [] } return columnFilters }, [columnFilters, globalFilter])
// Enhanced filter statistics const filterStats = useMemo(() => { // Check if using OR logic (stored in globalFilter as object) if ( typeof globalFilter === "object" && globalFilter && "filters" in globalFilter ) { const filterObj = globalFilter as { filters: Array<{ joinOperator?: string value?: unknown }> joinOperator: string } const filters = filterObj.filters || []
const hasAndFilters = filters.some( (filter, index) => index === 0 || filter.joinOperator === JOIN_OPERATORS.AND, ) const hasOrFilters = filters.some( (filter, index) => index > 0 && filter.joinOperator === JOIN_OPERATORS.OR, )
return { totalFilters: filters.length, hasAndFilters, hasOrFilters, effectiveJoinOperator: hasOrFilters ? JOIN_OPERATORS.MIXED : JOIN_OPERATORS.AND, activeFilters: filters.filter(f => f.value && f.value !== "").length, currentMode: filterMode, } }
// For AND logic (stored in columnFilters) const activeFilters = filterMode === "inline" ? inlineColumnFilters : standardColumnFilters const hasAndFilters = activeFilters.length > 0 const hasOrFilters = activeFilters.some( filter => typeof filter.value === "object" && filter.value && "joinOperator" in filter.value && filter.value.joinOperator === "or", )
return { totalFilters: activeFilters.length, hasAndFilters, hasOrFilters, effectiveJoinOperator: hasOrFilters ? JOIN_OPERATORS.MIXED : JOIN_OPERATORS.AND, activeFilters: activeFilters.filter(f => f.value && f.value !== "") .length, currentMode: filterMode, } }, [standardColumnFilters, inlineColumnFilters, filterMode, globalFilter])
// Get current filter mode const getFilterMode = () => { if ( typeof globalFilter === "object" && globalFilter && "filters" in globalFilter ) { const filterObj = globalFilter as { filters: unknown[] joinOperator: string } if (filterObj.joinOperator === "mixed") { return "MIXED" } return filterObj.joinOperator.toUpperCase() }
const hasOrOperators = columnFilters.some( filter => typeof filter.value === "object" && filter.value && "joinOperator" in filter.value && filter.value.joinOperator === "or", )
return hasOrOperators ? "MIXED" : "AND" }
// Handlers for pagination // Use functional update to avoid dependency on pagination state // This prevents stale closures and ensures the latest state is always used const handlePaginationChange = useCallback( (updater: Updater<PaginationState>) => { setPagination(prevPagination => { const newPagination = typeof updater === "function" ? updater(prevPagination) : updater return newPagination }) }, [], // Empty deps - functional update ensures we always use latest state )
// Handlers for sorting const handleSortingChange = useCallback( (updater: Updater<SortingState>) => { const newSorting = typeof updater === "function" ? updater(sorting) : updater setSorting(newSorting.length > 0 ? newSorting : []) }, [sorting], )
// Handlers for filters (standard mode) const handleStandardColumnFiltersChange = useCallback( (updater: Updater<ColumnFiltersState>) => { const newFilters = typeof updater === "function" ? updater(standardColumnFilters) : updater
setStandardColumnFilters(newFilters) setPagination(prev => ({ ...prev, pageIndex: 0 })) }, [standardColumnFilters], )
// Handlers for filters (inline mode) const handleInlineColumnFiltersChange = useCallback( (updater: Updater<ColumnFiltersState>) => { const newFilters = typeof updater === "function" ? updater(inlineColumnFilters) : updater
setInlineColumnFilters(newFilters) setPagination(prev => ({ ...prev, pageIndex: 0 })) }, [inlineColumnFilters], )
// Track previous globalFilter value to prevent infinite loops const prevGlobalFilterRef = useRef<string | object | undefined>(undefined)
// Handlers for global filter (handles both search string and OR filter object) const handleGlobalFilterChange = useCallback( (value: string | object) => { // Prevent infinite loops - check if value actually changed const valueStr = JSON.stringify(value) const prevStr = JSON.stringify(prevGlobalFilterRef.current) if (valueStr === prevStr) { return }
// Update ref before calling setState prevGlobalFilterRef.current = value
if (typeof value === "string") { // Simple search string // Don't clear globalFilter with empty string if we already have OR filters if ( value === "" && typeof globalFilter === "object" && globalFilter && "filters" in globalFilter ) { // Empty string received but globalFilter has OR filters - ignore to prevent clearing return }
setGlobalFilter(value) setPagination(prev => ({ ...prev, pageIndex: 0 })) } else { // OR filter object - store in globalFilter setGlobalFilter(value) setPagination(prev => ({ ...prev, pageIndex: 0 })) } }, [globalFilter], )
// Handlers for column visibility const handleColumnVisibilityChange = useCallback( (updater: Updater<VisibilityState>) => { const newVisibility = typeof updater === "function" ? updater(columnVisibility) : updater setColumnVisibility(newVisibility) }, [columnVisibility], )
// Extract ExtendedColumnFilter from globalFilter or columnFilters (standard mode) const currentStandardFilters = useMemo(() => { // Check if filters are in globalFilter (OR/MIXED logic) if ( typeof globalFilter === "object" && globalFilter && "filters" in globalFilter && filterMode === "standard" ) { const filterObj = globalFilter as { filters: ExtendedColumnFilter<Product>[] } return filterObj.filters || [] }
// Otherwise extract from columnFilters (AND logic) return standardColumnFilters .map(cf => cf.value) .filter( (v): v is ExtendedColumnFilter<Product> => v !== null && typeof v === "object" && "id" in v, ) }, [standardColumnFilters, globalFilter, filterMode])
// Normalize filters to ensure they have unique filterIds // The normalization function is deterministic (uses index-based IDs), so it produces // stable results when filters haven't changed, preventing unnecessary re-renders const normalizedStandardFilters = useMemo( () => normalizeFiltersWithUniqueIds(currentStandardFilters), [currentStandardFilters], )
// Extract ExtendedColumnFilter from globalFilter or columnFilters (inline mode) const currentInlineFilters = useMemo(() => { // Check if filters are in globalFilter (OR/MIXED logic) if ( typeof globalFilter === "object" && globalFilter && "filters" in globalFilter && filterMode === "inline" ) { const filterObj = globalFilter as { filters: ExtendedColumnFilter<Product>[] } return filterObj.filters || [] }
// Otherwise extract from columnFilters (AND logic) return inlineColumnFilters .map(cf => cf.value) .filter( (v): v is ExtendedColumnFilter<Product> => v !== null && typeof v === "object" && "id" in v, ) }, [inlineColumnFilters, globalFilter, filterMode])
// Normalize filters to ensure they have unique filterIds // The normalization function is deterministic (uses index-based IDs), so it produces // stable results when filters haven't changed, preventing unnecessary re-renders const normalizedInlineFilters = useMemo( () => normalizeFiltersWithUniqueIds(currentInlineFilters), [currentInlineFilters], )
// Direct filter change handlers - sync filter UI changes directly to state const handleStandardFiltersChange = useCallback( (filters: ExtendedColumnFilter<Product>[] | null) => { // When clearing filters (null or empty array), also clear globalFilter if (!filters || filters.length === 0) { setStandardColumnFilters([]) setGlobalFilter("") setPagination(prev => ({ ...prev, pageIndex: 0 })) } else { // Use core utility to process filters and determine routing const result = processFiltersForLogic(filters)
if (result.shouldUseGlobalFilter) { // Use globalFilter for OR/MIXED logic setStandardColumnFilters([]) setGlobalFilter({ filters: result.processedFilters, joinOperator: result.joinOperator, }) setPagination(prev => ({ ...prev, pageIndex: 0 })) } else { // Use columnFilters for AND logic const columnFiltersState: ColumnFiltersState = result.processedFilters.map(filter => ({ id: filter.id, value: filter, })) setStandardColumnFilters(columnFiltersState) setGlobalFilter("") setPagination(prev => ({ ...prev, pageIndex: 0 })) } } }, [], )
const handleInlineFiltersChange = useCallback( (filters: ExtendedColumnFilter<Product>[]) => { // When clearing filters (empty array), also clear globalFilter if (filters.length === 0) { setInlineColumnFilters([]) setGlobalFilter("") setPagination(prev => ({ ...prev, pageIndex: 0 })) } else { // Use core utility to process filters and determine routing const result = processFiltersForLogic(filters)
if (result.shouldUseGlobalFilter) { // Use globalFilter for OR/MIXED logic setInlineColumnFilters([]) setGlobalFilter({ filters: result.processedFilters, joinOperator: result.joinOperator, }) setPagination(prev => ({ ...prev, pageIndex: 0 })) } else { // Use columnFilters for AND logic const columnFiltersState: ColumnFiltersState = result.processedFilters.map(filter => ({ id: filter.id, value: filter, })) setInlineColumnFilters(columnFiltersState) setGlobalFilter("") setPagination(prev => ({ ...prev, pageIndex: 0 })) } } }, [], )
// Manual refresh function - TanStack Query handles refetching automatically const handleRefresh = useCallback(() => { refetch() }, [refetch])
// Calculate pageCount for manual pagination const pageCount = totalCount > 0 ? Math.ceil(totalCount / pagination.pageSize) : 1
return ( <div className="w-full space-y-4"> {/* Error Display */} {error && ( <Card className="border-destructive"> <CardContent className="flex items-center gap-2 pt-6"> <AlertCircle className="h-5 w-5 text-destructive" /> <div className="flex-1"> <p className="text-sm font-medium text-destructive"> Error loading data </p> <p className="text-xs text-muted-foreground">{error}</p> </div> <Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading} > Retry </Button> </CardContent> </Card> )}
<div className="flex justify-between space-y-4"> <Tabs value={filterMode} onValueChange={value => { const newMode = value as "standard" | "inline"
if (newMode === "standard") { // Switching to standard: clear inline filters setFilterMode("standard") setInlineColumnFilters([]) setPagination(prev => ({ ...prev, pageIndex: 0 })) } else { // Switching to inline: clear standard filters setFilterMode("inline") setStandardColumnFilters([]) setPagination(prev => ({ ...prev, pageIndex: 0 })) } }} className="w-full" > <div className="flex w-full items-center justify-between"> <TabsList> <TabsTrigger value="standard">Standard Filters</TabsTrigger> <TabsTrigger value="inline">Inline Filters</TabsTrigger> </TabsList>
{/* Loading Indicator */} {/* Only show loading indicator on initial load, not during pagination */} {isLoading && ( <div className="flex items-center gap-2 rounded-lg border bg-muted/30 p-2 text-xs"> <Loader2 className="h-3 w-3 animate-spin" /> <span className="text-muted-foreground"> Loading products from server... </span> </div> )} {/* Show subtle indicator during pagination/filtering (when using previous data) */} {isPlaceholderData && isFetching && ( <div className="flex items-center gap-2 rounded-lg border bg-muted/30 p-2 text-xs"> <Loader2 className="h-3 w-3 animate-spin" /> <span className="text-muted-foreground"> Loading new page... </span> </div> )} {/* Show prefetching indicators */} {!isLoading && !isPlaceholderData && ( <div className="flex items-center gap-2"> {prefetchingState.next && ( <div className="flex items-center gap-2 rounded-lg border bg-muted/30 p-2 text-xs"> <Loader2 className="h-3 w-3 animate-spin" /> <span className="text-muted-foreground"> Prefetching next page... </span> </div> )} {prefetchingState.previous && ( <div className="flex items-center gap-2 rounded-lg border bg-muted/30 p-2 text-xs"> <Loader2 className="h-3 w-3 animate-spin" /> <span className="text-muted-foreground"> Prefetching previous page... </span> </div> )} </div> )} </div>
<TabsContent value="standard" className="space-y-4"> <DataTableRoot data={data} columns={columns} isLoading={isLoading} config={{ manualPagination: true, manualFiltering: true, manualSorting: true, pageCount, }} state={{ globalFilter, sorting, columnFilters: standardColumnFilters, columnVisibility, pagination, }} onGlobalFilterChange={handleGlobalFilterChange} onSortingChange={handleSortingChange} onColumnFiltersChange={handleStandardColumnFiltersChange} onColumnVisibilityChange={handleColumnVisibilityChange} onPaginationChange={handlePaginationChange} > <StandardFilterToolbar filters={normalizedStandardFilters} onFiltersChange={handleStandardFiltersChange} /> <DataTable> <DataTableHeader /> <DataTableBody> <DataTableSkeleton rows={pagination.pageSize} /> <DataTableEmptyBody> <DataTableEmptyMessage> <DataTableEmptyIcon> <UserSearch className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle> No products found </DataTableEmptyTitle> <DataTableEmptyDescription> There are no products to display at this time. </DataTableEmptyDescription> </DataTableEmptyMessage> <DataTableEmptyFilteredMessage> <DataTableEmptyIcon> <SearchX className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle> No matches found </DataTableEmptyTitle> <DataTableEmptyDescription> Try adjusting your filters or search to find what you're looking for. </DataTableEmptyDescription> </DataTableEmptyFilteredMessage> </DataTableEmptyBody> </DataTableBody> </DataTable> <DataTablePagination totalCount={totalCount} isLoading={isLoading} isFetching={isFetching} disableNextPage={isLoading} disablePreviousPage={isLoading} /> </DataTableRoot> </TabsContent>
<TabsContent value="inline" className="space-y-4"> <DataTableRoot data={data} columns={columns} isLoading={isLoading} config={{ manualPagination: true, manualFiltering: true, manualSorting: true, pageCount, }} state={{ globalFilter, sorting, columnFilters: inlineColumnFilters, columnVisibility, pagination, }} onGlobalFilterChange={handleGlobalFilterChange} onSortingChange={handleSortingChange} onColumnFiltersChange={handleInlineColumnFiltersChange} onColumnVisibilityChange={handleColumnVisibilityChange} onPaginationChange={handlePaginationChange} > <InlineFilterToolbar filters={normalizedInlineFilters} onFiltersChange={handleInlineFiltersChange} /> <DataTable> <DataTableHeader /> <DataTableBody> <DataTableSkeleton rows={pagination.pageSize} /> <DataTableEmptyBody> <DataTableEmptyMessage> <DataTableEmptyIcon> <UserSearch className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle> No products found </DataTableEmptyTitle> <DataTableEmptyDescription> There are no products to display at this time. </DataTableEmptyDescription> </DataTableEmptyMessage> <DataTableEmptyFilteredMessage> <DataTableEmptyIcon> <SearchX className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle> No matches found </DataTableEmptyTitle> <DataTableEmptyDescription> Try adjusting your filters or search to find what you're looking for. </DataTableEmptyDescription> </DataTableEmptyFilteredMessage> </DataTableEmptyBody> </DataTableBody> </DataTable> <DataTablePagination totalCount={totalCount} isLoading={isLoading} isFetching={isFetching} disableNextPage={isLoading} disablePreviousPage={isLoading} /> </DataTableRoot> </TabsContent> </Tabs> </div>
{/* State Display for demonstration */} <Card> <CardHeader> <CardTitle>Server-Side Table State</CardTitle> <CardDescription> All state is managed with React useState. Data is fetched from a mocked API with delays using TanStack Query. State does NOT persist across page refreshes. </CardDescription> <CardAction> <div className="flex gap-2"> <Button variant="outline" size="sm" onClick={resetAllState}> Reset All State </Button> <Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading || isFetching} > {isLoading || isFetching ? ( <> <Loader2 className="mr-2 h-3 w-3 animate-spin" /> Loading... </> ) : ( "Refresh Data" )} </Button> </div> </CardAction> </CardHeader> <CardContent className="space-y-4"> <div className="grid gap-2 text-xs text-muted-foreground"> <div className="flex justify-between"> <span className="font-medium">Loading State:</span> <span className="text-foreground"> {isLoading ? ( <span className="flex items-center gap-1 text-blue-600"> <Loader2 className="h-3 w-3 animate-spin" /> Initial Loading... </span> ) : isPlaceholderData ? ( <span className="flex items-center gap-1 text-orange-600"> <Loader2 className="h-3 w-3 animate-spin" /> Loading (showing previous data)... </span> ) : isFetching ? ( <span className="flex items-center gap-1 text-yellow-600"> <Loader2 className="h-3 w-3 animate-spin" /> Refetching... </span> ) : ( <span className="text-green-600">✓ Ready</span> )} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Total Items (Server):</span> <span className="text-foreground">{totalCount}</span> </div>
<div className="flex justify-between"> <span className="font-medium">Current Page Items:</span> <span className="text-foreground">{data.length}</span> </div>
<div className="flex justify-between"> <span className="font-medium">Search Query:</span> <span className="text-foreground"> {getGlobalFilterDisplay()} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Filter Mode:</span> <span className="text-foreground">{filterMode}</span> </div>
<div className="flex justify-between"> <span className="font-medium">Active Filters:</span> <span className="text-foreground">{columnFilters.length}</span> </div>
<div className="flex justify-between"> <span className="font-medium">Standard Filters:</span> <span className="text-foreground"> {standardColumnFilters.length} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Inline Filters:</span> <span className="text-foreground"> {inlineColumnFilters.length} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Enhanced Filters:</span> <span className="text-foreground"> {filterStats.totalFilters} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Active Enhanced:</span> <span className="text-foreground"> {filterStats.activeFilters} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Join Logic:</span> <span className="text-foreground"> {filterStats.effectiveJoinOperator} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Sorting:</span> <span className="text-foreground"> {sorting.length > 0 ? sorting .map(s => `${s.id} ${s.desc ? "desc" : "asc"}`) .join(", ") : "None"} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Page:</span> <span className="text-foreground"> {pagination.pageIndex + 1} of{" "} {Math.ceil(totalCount / pagination.pageSize)} (Size:{" "} {pagination.pageSize}) </span> </div>
<div className="flex justify-between"> <span className="font-medium">Hidden Columns:</span> <span className="text-foreground"> { Object.values(columnVisibility).filter(v => v === false) .length } </span> </div> </div>
{/* Detailed state (collapsible) */} <details className="border-t pt-4"> <summary className="cursor-pointer text-xs font-medium hover:text-foreground"> View Full State Object </summary> <div className="mt-4 space-y-3 text-xs"> <div> <strong>Server Response:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify( { totalCount, currentPageItems: data.length, page: pagination.pageIndex + 1, pageSize: pagination.pageSize, }, null, 2, )} </pre> </div> <div> <strong>Enhanced Filters:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {displayFilters.length > 0 ? JSON.stringify(displayFilters, null, 2) : "No enhanced filters"} </pre> </div> <div> <strong>Filter Stats:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(filterStats, null, 2)} </pre> </div> <div> <strong>Filter Mode:</strong> {getFilterMode()} <div className="mt-1 text-muted-foreground"> {getFilterMode() === "AND" ? "All conditions must match (stored in columnFilters)" : getFilterMode() === "OR" ? "Any condition can match (stored in globalFilter)" : "Mixed logic - individual AND/OR operators per filter"} </div> </div> <div> <strong>Sorting:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(sorting, null, 2)} </pre> </div> <div> <strong>Column Filters State (AND logic):</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(columnFilters, null, 2)} </pre> </div> <div> <strong>Global Filter State (OR logic):</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(globalFilter, null, 2)} </pre> </div> <div> <strong>Pagination:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(pagination, null, 2)} </pre> </div> <div> <strong>Column Visibility:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(columnVisibility, null, 2)} </pre> </div> </div> </details>
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950"> <p className="text-xs text-blue-900 dark:text-blue-100"> <strong>💡 Tip:</strong> This example uses TanStack Query for server-side data fetching with automatic caching. State is managed with React useState and does NOT persist across page refreshes. For URL state persistence, see the{" "} <a href="/data-table/server-side-nuqs-table" className="underline hover:text-blue-700 dark:hover:text-blue-300" > Server-Side Nuqs Table </a>{" "} example. </p> </div> </CardContent> </Card> </div> )}
/** * Main component wrapped in QueryClientProvider * * This example includes QueryClientProvider at the component level * since it's a standalone example. * * For production apps, it's recommended to add this provider at your root layout instead: * - Next.js App Router: Wrap in app/layout.tsx * - Next.js Pages Router: Wrap in pages/_app.tsx * - React SPA: Wrap in src/main.tsx * * See the component documentation at the top of this file for detailed setup instructions. */export default function ServerSideStateTableExample() { // Create a QueryClient instance with sensible defaults for server-side data tables // Using useState with lazy initializer ensures it's only created once const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 30 * 1000, // Consider data fresh for 30 seconds gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes refetchOnWindowFocus: false, // Don't refetch on window focus for data tables retry: 1, // Retry failed requests once }, }, }), )
return ( <QueryClientProvider client={queryClient}> <ServerSideStateTableContent /> </QueryClientProvider> )}Introduction
Section titled “Introduction”The Server-Side Table demonstrates server-side pagination, sorting, and filtering using TanStack Query for efficient data fetching, caching, and state management. This example shows how to handle large datasets by fetching data from a server with automatic caching, background updates, and request deduplication. State is managed with React useState and does NOT persist across page refreshes.
Installation
Section titled “Installation”- Add the required components:
npx shadcn@latest add table input button dropdown-menu popover command checkbox select scroll-area separator skeleton tooltip- Add dependencies:
npm install @tanstack/react-table @tanstack/react-queryNote: TanStack Query provides powerful asynchronous state management, server-state utilities, and data fetching capabilities. It handles caching, background updates, and stale data out of the box with zero-configuration.
Required: Sortable Component
This example uses DataTableSortMenu and DataTableFilterMenu which require the Sortable component for drag-and-drop reordering. Follow the DiceUI Sortable installation guide.
- Copy the DataTable components into your project. See the Installation Guide for detailed instructions.
Before using the Server-Side Table, you need to wrap your app with QueryClientProvider. Follow the setup instructions based on your framework:
Next.js App Router
Section titled “Next.js App Router”Wrap {children} in app/layout.tsx:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // 1 minute refetchOnWindowFocus: false, }, },})
export default function RootLayout({ children }) { return ( <html> <body> <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> </body> </html> )}Next.js Pages Router
Section titled “Next.js Pages Router”Wrap <Component> in pages/_app.tsx:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, refetchOnWindowFocus: false, }, },})
export default function App({ Component, pageProps }) { return ( <QueryClientProvider client={queryClient}> <Component {...pageProps} /> </QueryClientProvider> )}React SPA (Vite, CRA, etc.)
Section titled “React SPA (Vite, CRA, etc.)”Wrap <App /> in src/main.tsx:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, refetchOnWindowFocus: false, }, },})
createRoot(document.getElementById("root")!).render( <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider>,)Prerequisites
Section titled “Prerequisites”We are going to build a table with server-side data fetching. Here’s what our data structure looks like:
type Product = { id: string name: string category: string brand: string price: number stock: number rating: number inStock: boolean releaseDate: Date}Server-Side Data Fetching
Section titled “Server-Side Data Fetching”The example uses TanStack Query’s useQuery hook to fetch data from a server. The query automatically refetches when pagination, sorting, or filters change.
API Function
Section titled “API Function”First, create a function that fetches data from your server:
type ServerResponse<T> = { data: T[] total: number page: number pageSize: number}
type FetchParams = { page: number pageSize: number sorting: SortingState globalFilter: string | object columnFilters: ColumnFiltersState}
async function fetchProducts( params: FetchParams,): Promise<ServerResponse<Product>> { // Build query string from params const queryParams = new URLSearchParams({ page: params.page.toString(), pageSize: params.pageSize.toString(), sort: JSON.stringify(params.sorting), filters: JSON.stringify(params.columnFilters), search: typeof params.globalFilter === "string" ? params.globalFilter : "", })
const response = await fetch(`/api/products?${queryParams}`) if (!response.ok) { throw new Error("Failed to fetch products") }
return response.json()}Using TanStack Query
Section titled “Using TanStack Query”Use useQuery to fetch data with automatic caching and refetching:
import { useState } from "react"import { useQuery, keepPreviousData } from "@tanstack/react-query"import type { SortingState, ColumnFiltersState } from "@tanstack/react-table"
function ServerSideTable() { const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) const [sorting, setSorting] = useState<SortingState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [globalFilter, setGlobalFilter] = useState<string | object>("")
const { data, isLoading, error } = useQuery({ queryKey: [ "products", pagination.pageIndex, pagination.pageSize, sorting, globalFilter, columnFilters, ], queryFn: () => fetchProducts({ page: pagination.pageIndex, pageSize: pagination.pageSize, sorting, globalFilter, columnFilters, }), staleTime: 30000, // Consider data fresh for 30 seconds gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes placeholderData: keepPreviousData, // Keep previous data visible during pagination })
const products = data?.data ?? [] const totalCount = data?.total ?? 0
return ( <DataTableRoot data={products} columns={columns} isLoading={isLoading} config={{ manualPagination: true, manualFiltering: true, manualSorting: true, pageCount: Math.ceil(totalCount / pagination.pageSize), }} state={{ pagination, sorting, columnFilters, globalFilter, }} onPaginationChange={setPagination} onSortingChange={setSorting} onColumnFiltersChange={setColumnFilters} onGlobalFilterChange={setGlobalFilter} > {/* Table components */} </DataTableRoot> )}Key Features
Section titled “Key Features”Automatic Caching
Section titled “Automatic Caching”TanStack Query automatically caches query results. When you navigate between pages and come back, the data is served from cache instantly.
Background Refetching
Section titled “Background Refetching”Data is automatically refetched in the background when it becomes stale, keeping your UI up-to-date without user intervention.
Request Deduplication
Section titled “Request Deduplication”Multiple components requesting the same data will share a single request, reducing server load.
Loading States
Section titled “Loading States”TanStack Query provides isLoading and isFetching states:
isLoading: True on initial load (no cached data)isFetching: True whenever a request is in progress (including background refetches)
const { data, isLoading, isFetching } = useQuery({ ... })
// Show skeleton on initial load{isLoading && <DataTableSkeleton rows={10} />}
// Show subtle indicator during background refetch{isFetching && !isLoading && <LoadingIndicator />}Prefetching Next/Previous Pages
Section titled “Prefetching Next/Previous Pages”To improve navigation performance, you can prefetch adjacent pages in the background. This makes page transitions feel instant when users navigate. You can also track prefetching state to show visual feedback to users:
import { useQueryClient } from "@tanstack/react-query"import { useEffect, useState, useRef } from "react"import { Loader2 } from "lucide-react"
function ServerSideTable() { const queryClient = useQueryClient() const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) // ... other state
// Track prefetching state for UI feedback const [prefetchingState, setPrefetchingState] = useState<{ next: boolean previous: boolean }>({ next: false, previous: false }) const effectRunRef = useRef(0)
// Prefetch next and previous pages useEffect(() => { const totalPages = Math.ceil(totalCount / pagination.pageSize) const currentPage = pagination.pageIndex const currentRun = ++effectRunRef.current
// Reset prefetching state at the start of each effect run setTimeout(() => { if (currentRun === effectRunRef.current) { setPrefetchingState({ next: false, previous: false }) } }, 0)
// Prefetch next page if it exists if (currentPage + 1 < totalPages) { setTimeout(() => { if (currentRun === effectRunRef.current) { setPrefetchingState(prev => ({ ...prev, next: true })) } }, 0)
queryClient .prefetchQuery({ queryKey: [ "products", currentPage + 1, pagination.pageSize, sorting, globalFilter, columnFilters, filterMode, // Include filterMode if you have filter modes ], queryFn: () => fetchProducts({ page: currentPage + 1, pageSize: pagination.pageSize, sorting, globalFilter, columnFilters, }), staleTime: 30000, }) .then(() => { if (currentRun === effectRunRef.current) { setPrefetchingState(prev => ({ ...prev, next: false })) } }) .catch(() => { if (currentRun === effectRunRef.current) { setPrefetchingState(prev => ({ ...prev, next: false })) } }) }
// Prefetch previous page if it exists if (currentPage > 0) { setTimeout(() => { if (currentRun === effectRunRef.current) { setPrefetchingState(prev => ({ ...prev, previous: true })) } }, 0)
queryClient .prefetchQuery({ queryKey: [ "products", currentPage - 1, pagination.pageSize, sorting, globalFilter, columnFilters, filterMode, // Include filterMode if you have filter modes ], queryFn: () => fetchProducts({ page: currentPage - 1, pageSize: pagination.pageSize, sorting, globalFilter, columnFilters, }), staleTime: 30000, }) .then(() => { if (currentRun === effectRunRef.current) { setPrefetchingState(prev => ({ ...prev, previous: false })) } }) .catch(() => { if (currentRun === effectRunRef.current) { setPrefetchingState(prev => ({ ...prev, previous: false })) } }) }
// Cleanup function: reset state if effect re-runs return () => { setPrefetchingState({ next: false, previous: false }) } }, [ queryClient, pagination.pageIndex, pagination.pageSize, totalCount, sorting, globalFilter, columnFilters, filterMode, // Add filterMode to dependencies if you use it ])
return ( <div> {/* Show prefetching indicators */} {prefetchingState.next && ( <div className="flex items-center gap-2 rounded-lg border bg-muted/30 p-2 text-xs"> <Loader2 className="h-3 w-3 animate-spin" /> <span className="text-muted-foreground"> Prefetching next page... </span> </div> )} {prefetchingState.previous && ( <div className="flex items-center gap-2 rounded-lg border bg-muted/30 p-2 text-xs"> <Loader2 className="h-3 w-3 animate-spin" /> <span className="text-muted-foreground"> Prefetching previous page... </span> </div> )}
{/* Table components */} </div> )}Key points about prefetching:
- State tracking: Use a ref (
effectRunRef) to track effect runs and prevent state updates from stale prefetch operations - State reset: Reset prefetching state at the start of each effect run to prevent stuck indicators
- Error handling: Always reset state in
.catch()handlers to prevent stuck indicators on errors - Cleanup: Reset state in the cleanup function when dependencies change
With prefetching enabled, navigation between pages feels instant because the data is already cached. The pagination buttons remain enabled during background fetching, allowing users to navigate to prefetched pages immediately. The prefetching indicators provide visual feedback so users know when background loading is happening.
Error Handling
Section titled “Error Handling”TanStack Query provides error states and retry logic:
const { data, error, isError } = useQuery({ queryKey: ['products', ...], queryFn: fetchProducts, retry: 1, // Retry failed requests once})
if (isError) { return <ErrorDisplay error={error} />}State Management
Section titled “State Management”This example uses React useState for state management. State does NOT persist across page refreshes. If you need URL state persistence, see the Server-Side Nuqs Table example.
function ServerSideTable() { // State management with useState const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) const [sorting, setSorting] = useState<SortingState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [globalFilter, setGlobalFilter] = useState<string | object>('')
// Use TanStack Query for data fetching const { data, isLoading } = useQuery({ queryKey: ['products', pagination, sorting, globalFilter, columnFilters], queryFn: () => fetchProducts({ ... }), })
// ... rest of component}Manual Pagination, Sorting, and Filtering
Section titled “Manual Pagination, Sorting, and Filtering”When using server-side operations, you need to enable manual modes:
<DataTableRoot config={{ manualPagination: true, // Server handles pagination manualSorting: true, // Server handles sorting manualFiltering: true, // Server handles filtering pageCount: totalPages, // Total pages from server }}/>Query Key Strategy
Section titled “Query Key Strategy”The query key should include all parameters that affect the data:
const queryKey = [ "products", // Resource name pagination.pageIndex, // Page number pagination.pageSize, // Page size sorting, // Sort configuration globalFilter, // Search query columnFilters, // Column filters]This ensures that:
- Different pages have separate cache entries
- Changing filters invalidates and refetches data
- Browser back/forward navigation uses cached data
Optimistic Updates
Section titled “Optimistic Updates”You can use TanStack Query mutations for optimistic updates:
import { useMutation, useQueryClient } from "@tanstack/react-query"
function useUpdateProduct() { const queryClient = useQueryClient()
return useMutation({ mutationFn: updateProduct, onMutate: async newProduct => { // Cancel outgoing refetches await queryClient.cancelQueries({ queryKey: ["products"] })
// Snapshot previous value const previousProducts = queryClient.getQueryData(["products"])
// Optimistically update queryClient.setQueryData(["products"], old => ({ ...old, data: old.data.map(p => (p.id === newProduct.id ? newProduct : p)), }))
return { previousProducts } }, onError: (err, newProduct, context) => { // Rollback on error queryClient.setQueryData(["products"], context.previousProducts) }, onSettled: () => { // Refetch after mutation queryClient.invalidateQueries({ queryKey: ["products"] }) }, })}When to Use
Section titled “When to Use”✅ Use Server-Side Table when:
- Working with large datasets (thousands+ rows)
- Data needs to be fetched from an API
- You want automatic caching and background updates
- You need request deduplication
- You want optimistic updates
- You need error handling and retry logic
- You don’t need URL state persistence
❌ Consider other options when:
- Working with small datasets (< 1000 rows) - use client-side filtering
- Data is static or rarely changes - simple state management may suffice
- You need URL state persistence - use Server-Side Nuqs Table
- You need bookmarkable/shareable table views - use Server-Side Nuqs Table
Best Practices
Section titled “Best Practices”- Set appropriate stale times: Balance freshness with performance
- Use query invalidation: Invalidate queries after mutations
- Implement proper error handling: Show user-friendly error messages
- Optimize query keys: Include all parameters that affect data
- Use loading states: Show skeletons during initial load
- Handle pagination: Reset to page 1 when filters change
- Use
placeholderData: keepPreviousData: Prevents UI jumps during pagination
Next Steps
Section titled “Next Steps”- Advanced Table - Learn about advanced filtering
- Server-Side Nuqs Table - Add URL state persistence
- TanStack Query Docs - Learn more about TanStack Query