* 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.
* 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:
* import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
* const queryClient = new QueryClient({
* staleTime: 60 * 1000, // 1 minute
* refetchOnWindowFocus: false,
* export default function RootLayout({ children }) {
* <QueryClientProvider client={queryClient}>
* For Next.js Pages Router:
* 1. Wrap your app with QueryClientProvider in pages/_app.tsx:
* import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
* const queryClient = new QueryClient({
* refetchOnWindowFocus: false,
* export default function App({ Component, pageProps }) {
* <QueryClientProvider client={queryClient}>
* <Component {...pageProps} />
* For React SPA (Vite, CRA, etc.):
* 1. Wrap your app with QueryClientProvider in src/main.tsx:
* import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
* const queryClient = new QueryClient({
* refetchOnWindowFocus: false,
* createRoot(document.getElementById('root')!).render(
* <QueryClientProvider client={queryClient}>
* - Automatic caching and refetching (TanStack Query)
* - Request deduplication
* - Loading and error states
* - Server-side pagination, sorting, and filtering
* - Optimistic updates support
* - Query invalidation support
* - keepPreviousData for smooth pagination
* - TanStack Query: https://tanstack.com/query/latest
} from "@tanstack/react-query"
} from "@tanstack/react-table"
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"
import { DataTable } from "@/components/niko-table/core/data-table"
} from "@/components/niko-table/core/data-table-structure"
import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"
import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"
import { DataTableColumnSortMenu } from "@/components/niko-table/components/data-table-column-sort"
import { DataTableColumnFacetedFilterMenu } from "@/components/niko-table/components/data-table-column-faceted-filter"
import { DataTableColumnSliderFilterMenu } from "@/components/niko-table/components/data-table-column-slider-filter-options"
import { DataTableColumnDateFilterMenu } from "@/components/niko-table/components/data-table-column-date-filter-options"
import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"
DataTableEmptyFilteredMessage,
DataTableEmptyDescription,
} from "@/components/niko-table/components/data-table-empty-state"
import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"
import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"
import { DataTableSortMenu } from "@/components/niko-table/components/data-table-sort-menu"
import { DataTableFilterMenu } from "@/components/niko-table/components/data-table-filter-menu"
import { DataTableInlineFilter } from "@/components/niko-table/components/data-table-inline-filter"
import { DataTablePagination } from "@/components/niko-table/components/data-table-pagination"
import { daysAgo } from "@/components/niko-table/lib/format"
} from "@/components/niko-table/lib/constants"
import { processFiltersForLogic } from "@/components/niko-table/lib/data-table"
} from "@/components/niko-table/types"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
} from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Loader2, AlertCircle, UserSearch, SearchX } from "lucide-react"
import { useDebounce } from "@/components/niko-table/hooks/use-debounce"
// Static product data - same as other examples for consistency
// In a real app, this would be fetched from a server
const initialData: Product[] = [
name: "Galaxy S24 Ultra",
releaseDate: daysAgo(10),
releaseDate: daysAgo(25),
releaseDate: daysAgo(50),
releaseDate: daysAgo(365),
releaseDate: daysAgo(90),
releaseDate: daysAgo(120),
releaseDate: daysAgo(15),
releaseDate: daysAgo(30),
releaseDate: daysAgo(180),
releaseDate: daysAgo(60),
releaseDate: daysAgo(45),
name: "Garden Tools Set",
releaseDate: daysAgo(75),
name: "Programming Book",
releaseDate: daysAgo(200),
releaseDate: daysAgo(150),
// Generate larger dataset by duplicating and varying the initial data
// In a real app, this would come from a server API
const 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)
id: `${baseProduct.id}-${variation}`,
? `${baseProduct.name} (${variation + 1})`
price: baseProduct.price + variation * 10,
stock: Math.max(0, baseProduct.stock - variation * 5),
rating: baseProduct.rating,
inStock: baseProduct.stock - variation * 5 > 0,
baseProduct.releaseDate.getTime() - variation * 24 * 60 * 60 * 1000,
// Server-side API simulation
type ServerResponse<T> = {
* Cross-filter facets — see ServerResponse in server-side-nuqs-state.tsx.
select: Record<string, Array<{ value: string; count: number }>>
range: Record<string, [number, number]>
globalFilter: string | object
columnFilters: ColumnFiltersState
// Helper function to check if a product matches a single filter
filter: ExtendedColumnFilter<Product>,
const productValue = product[filter.id as keyof Product]
const filterValue = filter.value
filter.operator === FILTER_OPERATORS.EMPTY ||
filter.operator === FILTER_OPERATORS.NOT_EMPTY
// These don't need a value
} else if (!filterValue || filterValue === "") {
switch (filter.operator) {
case FILTER_OPERATORS.EQ:
String(productValue).toLowerCase() === String(filterValue).toLowerCase()
case FILTER_OPERATORS.NEQ:
String(productValue).toLowerCase() !== String(filterValue).toLowerCase()
case FILTER_OPERATORS.ILIKE:
return String(productValue)
.includes(String(filterValue).toLowerCase())
case FILTER_OPERATORS.NOT_ILIKE:
return !String(productValue)
.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:
productValue === undefined ||
String(productValue).trim() === ""
case FILTER_OPERATORS.NOT_EMPTY:
productValue !== undefined &&
String(productValue).trim() !== ""
case FILTER_OPERATORS.IN:
if (Array.isArray(filterValue)) {
v => String(productValue).toLowerCase() === String(v).toLowerCase(),
case FILTER_OPERATORS.NOT_IN:
if (Array.isArray(filterValue)) {
return !filterValue.some(
v => String(productValue).toLowerCase() === String(v).toLowerCase(),
* Dispatch a TanStack column-filter value against a product.
* WHY: The column header carries multiple filter UIs (slider, date, faceted
* multi-select, boolean, text) that all call `column.setFilterValue(rawValue)`.
* The filter menu, by contrast, stuffs an `ExtendedColumnFilter` object into
* `filter.value`. Both shapes flow through the same TanStack columnFilters
* array — this function picks the right matcher per value shape.
* IMPACT: Slider/date/faceted/boolean filters now actually filter rows on the
* server (previously the early-return at the call site silently dropped them).
* WHAT: Detects ExtendedColumnFilter via `operator` field. Falls back to value
* shape: number tuple → range, date tuple → date range, string[] → IN,
* boolean → equals, string → ILIKE.
function matchesColumnFilter(
if (value === null || value === undefined || value === "") return true
typeof value === "object" &&
return matchesFilter(product, value as ExtendedColumnFilter<Product>)
const productValue = product[columnId as keyof Product]
if (Array.isArray(value) && value.length === 2 && !isStringArray(value)) {
const [a, b] = value as [unknown, unknown]
if (a == null && b == null) return true
productValue instanceof Date
const lo = a == null ? -Infinity : toNumber(a)
const hi = b == null ? Infinity : toNumber(b)
return productNum >= lo && productNum <= hi
if (Array.isArray(value)) {
if (value.length === 0) return true
v => String(productValue).toLowerCase() === String(v).toLowerCase(),
if (typeof value === "boolean") {
return Boolean(productValue) === value
if (typeof value === "string" || typeof value === "number") {
return String(productValue)
.includes(String(value).toLowerCase())
function isStringArray(arr: unknown[]): boolean {
return arr.every(v => typeof v === "string")
function toNumber(v: unknown): number {
if (v instanceof Date) return v.getTime()
// Filter products using all filter params, optionally excluding one column's filter.
// Used for both main data filtering and facet computation (where we exclude the
// facet column's own filter so users can see all available values for that column).
function filterProductsByParams(
excludeColumnId?: string,
let filtered = [...products]
// 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)
typeof params.globalFilter === "object" &&
"filters" in params.globalFilter
const filterObj = params.globalFilter as {
filters: ExtendedColumnFilter<Product>[]
const orFilters = (filterObj.filters || []).filter(
(!excludeColumnId || f.id !== excludeColumnId),
if (orFilters.length > 0) {
filtered = filtered.filter(product =>
orFilters.some(filter => matchesFilter(product, filter)),
// Apply AND filters from columnFilters (server-side).
// Values can be either an `ExtendedColumnFilter` (from the filter menu) or a
// plain TanStack column-filter value (tuple from slider/date, array from
// multi-select, boolean, string). Dispatch on shape so all column-header
// filter UIs actually filter the row set.
if (params.columnFilters.length > 0) {
filtered = filtered.filter(product => {
return params.columnFilters.every(filter => {
if (excludeColumnId && filter.id === excludeColumnId) return true
return matchesColumnFilter(product, filter.id, filter.value)
// Simulate server-side filtering, sorting, and pagination
): Promise<ServerResponse<Product>> {
return new Promise((resolve, reject) => {
// Simulate occasional errors (5% chance)
if (Math.random() < 0.05) {
reject(new Error("Server error: Failed to fetch products"))
// Generate a large dataset
const allProducts = generateMockProducts(500)
const filtered = filterProductsByParams(allProducts, params)
// Cross-filter facets — see server-side-nuqs-state.tsx for the full doc.
const selectColumns = ["category", "brand"] as const
const rangeColumns = ["price"] as const
const facets: ServerResponse<Product>["facets"] = {
for (const col of selectColumns) {
const facetFiltered = filterProductsByParams(allProducts, params, col)
const counts = new Map<string, number>()
for (const p of facetFiltered) {
const v = String(p[col as keyof Product])
counts.set(v, (counts.get(v) ?? 0) + 1)
facets.select[col] = [...counts.entries()]
.map(([value, count]) => ({ value, count }))
.sort((a, b) => a.value.localeCompare(b.value))
for (const col of rangeColumns) {
const facetFiltered = filterProductsByParams(allProducts, params, col)
if (facetFiltered.length === 0) continue
for (const p of facetFiltered) {
const v = Number(p[col as keyof Product])
if (Number.isFinite(v)) {
if (Number.isFinite(lo) && Number.isFinite(hi)) {
facets.range[col] = [lo, hi]
// 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
const total = filtered.length
const start = params.page * params.pageSize
const end = start + params.pageSize
const paginated = filtered.slice(start, end)
pageSize: params.pageSize,
const categoryOptions = [
{ label: "Electronics", value: "electronics" },
{ label: "Clothing", value: "clothing" },
{ label: "Home & Garden", value: "home-garden" },
{ label: "Sports", value: "sports" },
{ label: "Books", value: "books" },
{ 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>[] = [
<DataTableColumnSortMenu />
variant: FILTER_VARIANTS.TEXT,
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />
<DataTableColumnFacetedFilterMenu />
variant: FILTER_VARIANTS.SELECT,
options: categoryOptions,
// Disable auto-generation for server-side tables - use static options as-is
// since coreRows only contain server-filtered data, not the full dataset
const category = row.getValue("category") as string
const option = categoryOptions.find(opt => opt.value === category)
return <span>{option?.label || category}</span>
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />
<DataTableColumnFacetedFilterMenu />
variant: FILTER_VARIANTS.SELECT,
// Disable auto-generation for server-side tables - use static options as-is
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />
<DataTableColumnSliderFilterMenu />
variant: FILTER_VARIANTS.NUMBER,
const price = parseFloat(row.getValue("price"))
return <div className="font-medium">${price.toFixed(2)}</div>
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />
variant: FILTER_VARIANTS.NUMBER,
const stock = Number(row.getValue("stock"))
<div className={stock < 10 ? "font-medium text-red-600" : ""}>
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />
variant: FILTER_VARIANTS.NUMBER,
const rating = Number(row.getValue("rating"))
<div className="flex items-center gap-1">
<span className="text-yellow-500">★</span>
enableColumnFilter: true,
<DataTableColumnSortMenu />
<DataTableColumnFacetedFilterMenu />
variant: FILTER_VARIANTS.BOOLEAN,
const inStock = Boolean(row.getValue("inStock"))
<Badge variant={inStock ? "default" : "secondary"}>
enableColumnFilter: true,
accessorKey: "releaseDate",
<DataTableColumnSortMenu />
<DataTableColumnDateFilterMenu />
variant: FILTER_VARIANTS.DATE,
const date = row.getValue("releaseDate") as Date
return <span>{date.toLocaleDateString()}</span>
enableColumnFilter: true,
function StandardFilterToolbar({
filters: ExtendedColumnFilter<Product>[]
onFiltersChange: (filters: ExtendedColumnFilter<Product>[] | null) => void
<DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
<DataTableSearchFilter placeholder="Search products..." />
</DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
<DataTableSortMenu className="ml-auto" />
onFiltersChange={onFiltersChange}
</DataTableToolbarSection>
</DataTableToolbarSection>
function InlineFilterToolbar({
filters: ExtendedColumnFilter<Product>[]
onFiltersChange: (filters: ExtendedColumnFilter<Product>[]) => void
<DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
<DataTableSearchFilter placeholder="Search products..." />
</DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
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>(
| 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,
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}`
.replace(/[^a-z0-9-]/g, "-")
} 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}`
.replace(/[^a-z0-9-]/g, "-")
// Ensure the generated ID is unique (in case of collisions)
while (seenIds.has(finalId)) {
finalId = `${uniqueId}-${counter}`
} as ExtendedColumnFilter<TData>
function ServerSideStateTableContent() {
// State management with useState - no URL persistence
const [filterMode, setFilterMode] = useState<"standard" | "inline">(
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>({
// Get active column filters based on filter mode
filterMode === "standard" ? standardColumnFilters : inlineColumnFilters
// PERFORMANCE: Debounce column filters to batch rapid filter changes (e.g. clicking
// multiple faceted filter options) into a single server request instead of one per click
const debouncedColumnFilters = useDebounce(columnFilters, 300)
// 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
page: pagination.pageIndex,
pageSize: pagination.pageSize,
columnFilters: debouncedColumnFilters,
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, total, and facets from query response
const data = queryData?.data ?? []
const totalCount = queryData?.total ?? 0
const facets = queryData?.facets
// Create dynamic columns with cross-filter facets piped in from the server.
// Faceted columns receive `options` so unselected values stay visible after
// a filter narrows the row set. Slider columns receive `range={facets.range[col]}`
// so the slider can still be widened back. Each column's own filter is
// excluded from its facet computation server-side.
const dynamicColumns = useMemo(() => {
* Cross-filter narrowing — see server-side-nuqs-state.tsx for full doc.
* The faceted filter hides options with `count === 0` by default, so we
* just merge counts; absent values get count 0 and disappear.
staticOpts: typeof categoryOptions,
facet: Array<{ value: string; count: number }> | undefined,
if (!facet) return staticOpts
const m = new Map(facet.map(f => [f.value, f.count]))
return staticOpts.map(opt => ({ ...opt, count: m.get(opt.value) ?? 0 }))
const categoryOpts = mergeCounts(categoryOptions, facets?.select.category)
const brandOpts = mergeCounts(brandOptions, facets?.select.brand)
const priceRange = facets?.range.price
return columns.map(col => {
const key = (col as { accessorKey?: string }).accessorKey
if (key === "category") {
<DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />
<DataTableColumnFacetedFilterMenu options={categoryOpts} />
} as DataTableColumnDef<Product>
<DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />
<DataTableColumnFacetedFilterMenu options={brandOpts} />
} as DataTableColumnDef<Product>
if (key === "price" && priceRange) {
<DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />
<DataTableColumnSliderFilterMenu range={priceRange} />
} as DataTableColumnDef<Product>
? queryError instanceof Error
// Get query client for prefetching
const queryClient = useQueryClient()
// Track prefetching state
const [prefetchingState, setPrefetchingState] = useState<{
}>({ next: false, previous: false })
const effectRunRef = useRef(0)
const abortControllerRef = useRef<AbortController | null>(null)
// Prefetch next and previous pages for smoother navigation
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
setPrefetchingState({ next: false, previous: false })
// Helper to safely update state only if this effect run is still current
updater: (prev: { next: boolean; previous: boolean }) => {
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 }))
pageSize: pagination.pageSize,
safeSetState(prev => ({ ...prev, next: false }))
safeSetState(prev => ({ ...prev, next: false }))
// Prefetch previous page if it exists
if (currentPage > 0 && !abortController.signal.aborted) {
safeSetState(prev => ({ ...prev, previous: true }))
pageSize: pagination.pageSize,
safeSetState(prev => ({ ...prev, previous: false }))
safeSetState(prev => ({ ...prev, previous: false }))
// Cleanup function: reset state and abort operations when effect re-runs
// 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.
const resetAllState = useCallback(() => {
setStandardColumnFilters([])
setInlineColumnFilters([])
setPagination({ pageIndex: 0, pageSize: 10 })
setFilterMode("standard")
// Helper to display global filter state
const getGlobalFilterDisplay = () => {
if (typeof globalFilter === "string") {
return globalFilter || "None"
typeof globalFilter === "object" &&
"filters" in globalFilter
const filterObj = globalFilter as {
return `OR Filter (${filterObj.filters?.length || 0} conditions)`
// Extract actual filter data for display
const displayFilters = useMemo(() => {
typeof globalFilter === "object" &&
"filters" in globalFilter
const filterObj = globalFilter as {
return filterObj.filters || []
}, [columnFilters, globalFilter])
// Enhanced filter statistics
const filterStats = useMemo(() => {
// Check if using OR logic (stored in globalFilter as object)
typeof globalFilter === "object" &&
"filters" in globalFilter
const filterObj = globalFilter as {
const filters = filterObj.filters || []
const hasAndFilters = filters.some(
index === 0 || filter.joinOperator === JOIN_OPERATORS.AND,
const hasOrFilters = filters.some(
index > 0 && filter.joinOperator === JOIN_OPERATORS.OR,
totalFilters: filters.length,
effectiveJoinOperator: hasOrFilters
activeFilters: filters.filter(f => f.value && f.value !== "").length,
// For AND logic (stored in columnFilters)
filterMode === "inline" ? inlineColumnFilters : standardColumnFilters
const hasAndFilters = activeFilters.length > 0
const hasOrFilters = activeFilters.some(
typeof filter.value === "object" &&
"joinOperator" in filter.value &&
filter.value.joinOperator === "or",
totalFilters: activeFilters.length,
effectiveJoinOperator: hasOrFilters
activeFilters: activeFilters.filter(f => f.value && f.value !== "")
}, [standardColumnFilters, inlineColumnFilters, filterMode, globalFilter])
// Get current filter mode
const getFilterMode = () => {
typeof globalFilter === "object" &&
"filters" in globalFilter
const filterObj = globalFilter as {
if (filterObj.joinOperator === "mixed") {
return filterObj.joinOperator.toUpperCase()
const hasOrOperators = columnFilters.some(
typeof filter.value === "object" &&
"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 => {
typeof updater === "function" ? updater(prevPagination) : updater
[], // Empty deps - functional update ensures we always use latest state
const handleSortingChange = useCallback(
(updater: Updater<SortingState>) => {
typeof updater === "function" ? updater(sorting) : updater
setSorting(newSorting.length > 0 ? newSorting : [])
// Handlers for filters (standard mode)
const handleStandardColumnFiltersChange = useCallback(
(updater: Updater<ColumnFiltersState>) => {
typeof updater === "function" ? updater(standardColumnFilters) : updater
setStandardColumnFilters(newFilters)
setPagination(prev => ({ ...prev, pageIndex: 0 }))
// Handlers for filters (inline mode)
const handleInlineColumnFiltersChange = useCallback(
(updater: Updater<ColumnFiltersState>) => {
typeof updater === "function" ? updater(inlineColumnFilters) : updater
setInlineColumnFilters(newFilters)
setPagination(prev => ({ ...prev, pageIndex: 0 }))
// 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) {
// Update ref before calling setState
prevGlobalFilterRef.current = value
if (typeof value === "string") {
// Don't clear globalFilter with empty string if we already have OR filters
typeof globalFilter === "object" &&
"filters" in globalFilter
// Empty string received but globalFilter has OR filters - ignore to prevent clearing
setPagination(prev => ({ ...prev, pageIndex: 0 }))
// OR filter object - store in globalFilter
setPagination(prev => ({ ...prev, pageIndex: 0 }))
// Handlers for column visibility
const handleColumnVisibilityChange = useCallback(
(updater: Updater<VisibilityState>) => {
typeof updater === "function" ? updater(columnVisibility) : updater
setColumnVisibility(newVisibility)
// Extract ExtendedColumnFilter from globalFilter or columnFilters (standard mode)
const currentStandardFilters = useMemo(() => {
// Check if filters are in globalFilter (OR/MIXED logic)
typeof globalFilter === "object" &&
"filters" in globalFilter &&
filterMode === "standard"
const filterObj = globalFilter as {
filters: ExtendedColumnFilter<Product>[]
return filterObj.filters || []
// Otherwise extract from columnFilters (AND logic)
return standardColumnFilters
(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)
typeof globalFilter === "object" &&
"filters" in globalFilter &&
const filterObj = globalFilter as {
filters: ExtendedColumnFilter<Product>[]
return filterObj.filters || []
// Otherwise extract from columnFilters (AND logic)
return inlineColumnFilters
(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),
// 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([])
setPagination(prev => ({ ...prev, pageIndex: 0 }))
// Use core utility to process filters and determine routing
const result = processFiltersForLogic(filters)
if (result.shouldUseGlobalFilter) {
// Use globalFilter for OR/MIXED logic
setStandardColumnFilters([])
filters: result.processedFilters,
joinOperator: result.joinOperator,
setPagination(prev => ({ ...prev, pageIndex: 0 }))
// Use columnFilters for AND logic
const columnFiltersState: ColumnFiltersState =
result.processedFilters.map(filter => ({
setStandardColumnFilters(columnFiltersState)
setPagination(prev => ({ ...prev, pageIndex: 0 }))
const handleInlineFiltersChange = useCallback(
(filters: ExtendedColumnFilter<Product>[]) => {
// When clearing filters (empty array), also clear globalFilter
if (filters.length === 0) {
setInlineColumnFilters([])
setPagination(prev => ({ ...prev, pageIndex: 0 }))
// Use core utility to process filters and determine routing
const result = processFiltersForLogic(filters)
if (result.shouldUseGlobalFilter) {
// Use globalFilter for OR/MIXED logic
setInlineColumnFilters([])
filters: result.processedFilters,
joinOperator: result.joinOperator,
setPagination(prev => ({ ...prev, pageIndex: 0 }))
// Use columnFilters for AND logic
const columnFiltersState: ColumnFiltersState =
result.processedFilters.map(filter => ({
setInlineColumnFilters(columnFiltersState)
setPagination(prev => ({ ...prev, pageIndex: 0 }))
// Manual refresh function - TanStack Query handles refetching automatically
const handleRefresh = useCallback(() => {
// Calculate pageCount for manual pagination
totalCount > 0 ? Math.ceil(totalCount / pagination.pageSize) : 1
<div className="w-full space-y-4">
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 pt-6">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm font-medium text-destructive">
<p className="text-xs text-muted-foreground">{error}</p>
<div className="flex justify-between space-y-4">
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 }))
// Switching to inline: clear standard filters
setStandardColumnFilters([])
setPagination(prev => ({ ...prev, pageIndex: 0 }))
<div className="flex w-full items-center justify-between">
<TabsTrigger value="standard">Standard Filters</TabsTrigger>
<TabsTrigger value="inline">Inline Filters</TabsTrigger>
{/* Loading Indicator */}
{/* Only show loading indicator on initial load, not during pagination */}
<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...
{/* 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">
{/* 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">
{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...
<TabsContent value="standard" className="space-y-4">
columnFilters: standardColumnFilters,
onGlobalFilterChange={handleGlobalFilterChange}
onSortingChange={handleSortingChange}
onColumnFiltersChange={handleStandardColumnFiltersChange}
onColumnVisibilityChange={handleColumnVisibilityChange}
onPaginationChange={handlePaginationChange}
filters={normalizedStandardFilters}
onFiltersChange={handleStandardFiltersChange}
<DataTableSkeleton rows={pagination.pageSize} />
<UserSearch className="size-12" />
<DataTableEmptyDescription>
There are no products to display at this time.
</DataTableEmptyDescription>
<DataTableEmptyFilteredMessage>
<SearchX className="size-12" />
<DataTableEmptyDescription>
Try adjusting your filters or search to find what
</DataTableEmptyDescription>
</DataTableEmptyFilteredMessage>
disableNextPage={isLoading}
disablePreviousPage={isLoading}
<TabsContent value="inline" className="space-y-4">
columnFilters: inlineColumnFilters,
onGlobalFilterChange={handleGlobalFilterChange}
onSortingChange={handleSortingChange}
onColumnFiltersChange={handleInlineColumnFiltersChange}
onColumnVisibilityChange={handleColumnVisibilityChange}
onPaginationChange={handlePaginationChange}
filters={normalizedInlineFilters}
onFiltersChange={handleInlineFiltersChange}
<DataTableSkeleton rows={pagination.pageSize} />
<UserSearch className="size-12" />
<DataTableEmptyDescription>
There are no products to display at this time.
</DataTableEmptyDescription>
<DataTableEmptyFilteredMessage>
<SearchX className="size-12" />
<DataTableEmptyDescription>
Try adjusting your filters or search to find what
</DataTableEmptyDescription>
</DataTableEmptyFilteredMessage>
disableNextPage={isLoading}
disablePreviousPage={isLoading}
{/* State Display for demonstration */}
<CardTitle>Server-Side Table State</CardTitle>
All state is managed with React useState. Data is fetched from a
mocked API with delays using TanStack Query. State does NOT persist
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={resetAllState}>
disabled={isLoading || isFetching}
{isLoading || isFetching ? (
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
<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">
<span className="flex items-center gap-1 text-blue-600">
<Loader2 className="h-3 w-3 animate-spin" />
) : isPlaceholderData ? (
<span className="flex items-center gap-1 text-orange-600">
<Loader2 className="h-3 w-3 animate-spin" />
Loading (showing previous data)...
<span className="flex items-center gap-1 text-yellow-600">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-green-600">✓ Ready</span>
<div className="flex justify-between">
<span className="font-medium">Total Items (Server):</span>
<span className="text-foreground">{totalCount}</span>
<div className="flex justify-between">
<span className="font-medium">Current Page Items:</span>
<span className="text-foreground">{data.length}</span>
<div className="flex justify-between">
<span className="font-medium">Search Query:</span>
<span className="text-foreground">
{getGlobalFilterDisplay()}
<div className="flex justify-between">
<span className="font-medium">Filter Mode:</span>
<span className="text-foreground">{filterMode}</span>
<div className="flex justify-between">
<span className="font-medium">Active Filters:</span>
<span className="text-foreground">{columnFilters.length}</span>
<div className="flex justify-between">
<span className="font-medium">Standard Filters:</span>
<span className="text-foreground">
{standardColumnFilters.length}
<div className="flex justify-between">
<span className="font-medium">Inline Filters:</span>
<span className="text-foreground">
{inlineColumnFilters.length}
<div className="flex justify-between">
<span className="font-medium">Enhanced Filters:</span>
<span className="text-foreground">
{filterStats.totalFilters}
<div className="flex justify-between">
<span className="font-medium">Active Enhanced:</span>
<span className="text-foreground">
{filterStats.activeFilters}
<div className="flex justify-between">
<span className="font-medium">Join Logic:</span>
<span className="text-foreground">
{filterStats.effectiveJoinOperator}
<div className="flex justify-between">
<span className="font-medium">Sorting:</span>
<span className="text-foreground">
.map(s => `${s.id} ${s.desc ? "desc" : "asc"}`)
<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:{" "}
<div className="flex justify-between">
<span className="font-medium">Hidden Columns:</span>
<span className="text-foreground">
Object.values(columnVisibility).filter(v => v === false)
{/* Detailed state (collapsible) */}
<details className="border-t pt-4">
<summary className="cursor-pointer text-xs font-medium hover:text-foreground">
<div className="mt-4 space-y-3 text-xs">
<strong>Server Response:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
currentPageItems: data.length,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
<strong>Enhanced Filters:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{displayFilters.length > 0
? JSON.stringify(displayFilters, null, 2)
<strong>Filter Stats:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(filterStats, null, 2)}
<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"}
<strong>Sorting:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(sorting, null, 2)}
<strong>Column Filters State (AND logic):</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(columnFilters, null, 2)}
<strong>Global Filter State (OR logic):</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(globalFilter, null, 2)}
<strong>Pagination:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(pagination, null, 2)}
<strong>Column Visibility:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(columnVisibility, null, 2)}
<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{" "}
href="/data-table/server-side-nuqs-table"
className="underline hover:text-blue-700 dark:hover:text-blue-300"
* 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(
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
<QueryClientProvider client={queryClient}>
<ServerSideStateTableContent />