* Server-Side Data Table Example with TanStack Query and URL State (nuqs)
* This example demonstrates server-side pagination, sorting, and filtering
* using TanStack Query for efficient data fetching, caching, and state management,
* combined with nuqs for URL state persistence. This allows users to bookmark,
* share, and refresh the page while maintaining their table state.
* 1. Install TanStack Query:
* npm install @tanstack/react-query
* IMPORTANT SETUP REQUIRED:
* This component includes both QueryClientProvider and NuqsAdapter at the component level
* for a complete, self-contained example. For production apps, it's recommended to add
* these providers at your root layout instead:
* For Next.js App Router:
* 1. Wrap your app with both providers in app/layout.tsx:
* import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
* import { NuqsAdapter } from 'nuqs/adapters/next/app'
* const queryClient = new QueryClient({
* staleTime: 60 * 1000, // 1 minute
* refetchOnWindowFocus: false,
* export default function RootLayout({ children }) {
* <QueryClientProvider client={queryClient}>
* <NuqsAdapter>{children}</NuqsAdapter>
* For Next.js Pages Router:
* 1. Wrap your app with both providers in pages/_app.tsx:
* import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
* import { NuqsAdapter } from 'nuqs/adapters/next/pages'
* 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 both providers in src/main.tsx:
* import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
* import { NuqsAdapter } from 'nuqs/adapters/react'
* 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
* - URL state persistence (nuqs) - bookmarkable and shareable
* - Proper refresh behavior - state survives page reloads
* - Optimistic updates support
* - Query invalidation support
* - keepPreviousData for smooth pagination
* - TanStack Query: https://tanstack.com/query/latest
* - nuqs: https://nuqs.dev
} from "@tanstack/react-query"
import { NuqsAdapter } from "nuqs/adapters/react"
} 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, formatQueryString } from "@/components/niko-table/lib/format"
} from "@/components/niko-table/lib/constants"
import { processFiltersForLogic } from "@/components/niko-table/lib/data-table"
import { serializeFiltersForUrl } from "@/components/niko-table/filters/table-filter-menu"
} 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 advanced-nuqs-state.tsx 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 keyed by column id.
* - select columns → `{ value, count }[]` for values present in the
* cross-filtered dataset. Missing static options still render in the UI
* (with count 0) so users can pivot to them; we just don't ship zero rows.
* - range columns → `[min, max]` tuple still available given other filters.
* "Cross-filter" means each column's facet is computed by re-applying ALL
* filters EXCEPT this column's own — so a user who's already filtered on
* price can still widen the slider back, and the brand list still keeps
* every brand visible (with cross-filter counts) regardless of the active
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
// ExtendedColumnFilter (from filter menu) — has an `operator` field.
typeof value === "object" &&
return matchesFilter(product, value as ExtendedColumnFilter<Product>)
const productValue = product[columnId as keyof Product]
// Tuple from slider or date filter: [min, max] or [start, end].
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
// Multi-select array: string[] from faceted filter.
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
// Text filter — case-insensitive contains.
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()
* Pull an `ExtendedColumnFilter` out of a URL entry, regardless of which
* shape was written. Used by the filter-menu memos to ignore raw column
* filters (slider tuples, etc.) that don't belong to the menu.
function extractExtendedFilter(
): ExtendedColumnFilter<Product> | null {
if (!entry || typeof entry !== "object") return null
const e = entry as { id?: string; value?: unknown }
// New shape: { id, value: ExtendedColumnFilter }
if (typeof e.id === "string" && e.value && typeof e.value === "object") {
if ("operator" in (e.value as Record<string, unknown>)) {
return e.value as ExtendedColumnFilter<Product>
// Legacy shape: the entry IS an ExtendedColumnFilter
if ("operator" in (e as Record<string, unknown>)) {
return e as unknown as ExtendedColumnFilter<Product>
// 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 from boolean filter, string from text). Both shapes
// are produced by the round-trip in `handleStandardColumnFiltersChange` and
// both must be honored here so the server filters the row set in either flow.
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 — each column's facet is computed against the
// dataset filtered by ALL OTHER column filters (excluding the column's
// own). Lets the user widen a slider back / unselect a faceted option
// without losing the rest of the filter set.
const selectColumns = ["category", "brand"] as const
// Add more columns here when you wire `DataTableColumnSliderFilterMenu`
// into their headers — the dispatcher in `dynamicColumns` will pick
// up the range automatically.
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
onSearchChange: (value: string) => void
<DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
placeholder="Search products..."
onChange={onSearchChange}
</DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
<DataTableSortMenu className="ml-auto" />
onFiltersChange={onFiltersChange}
</DataTableToolbarSection>
</DataTableToolbarSection>
function InlineFilterToolbar({
filters: ExtendedColumnFilter<Product>[]
onFiltersChange: (filters: ExtendedColumnFilter<Product>[]) => void
onSearchChange: (value: string) => void
<DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
placeholder="Search products..."
onChange={onSearchChange}
</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>
// Define parsers for URL state management (following nuqs best practices)
const tableStateParsers = {
pageIndex: parseAsInteger.withDefault(0),
pageSize: parseAsInteger.withDefault(10),
sort: parseAsJson<SortingState>(value => value as SortingState).withDefault(
filters: parseAsJson<ExtendedColumnFilter<Product>[]>(
value => value as ExtendedColumnFilter<Product>[],
search: parseAsString.withDefault(""),
// globalFilter should only be used for complex filter objects (OR/MIXED logic)
// Simple text search uses the "search" param instead
// When null/empty, nuqs will remove it from the URL
globalFilter: parseAsJson<{ filters: unknown[]; joinOperator: string }>(
// Only accept objects with filters (complex filter logic)
if (value && typeof value === "object" && "filters" in value) {
return value as { filters: unknown[]; joinOperator: string }
// Reject everything else (strings, empty strings, etc.)
// Return undefined to trigger default, which will be null
return undefined as unknown as {
null as unknown as { filters: unknown[]; joinOperator: string },
columnVisibility: parseAsJson<VisibilityState>(
value => value as VisibilityState,
inlineFilters: parseAsJson<ExtendedColumnFilter<Product>[]>(
value => value as ExtendedColumnFilter<Product>[],
filterMode: parseAsString.withDefault("standard"),
pin: parseAsJson<ColumnPinningState>(
value => value as ColumnPinningState,
).withDefault({ left: [], right: [] }),
// Map internal state keys to URL query parameter names
const tableStateUrlKeys = {
columnVisibility: "cols",
function ServerSideStateTableContent() {
// URL state management with nuqs - using built-in parsers and URL key mapping
const [urlParams, setUrlParams] = useQueryStates(tableStateParsers, {
urlKeys: tableStateUrlKeys,
// Get filter mode from URL
const filterMode = (urlParams.filterMode || "standard") as
// Global filter from URL - handle both search string and OR filters
const globalFilter = useMemo(() => {
// If globalFilter is stored in URL as object (OR/MIXED logic), use it
urlParams.globalFilter &&
typeof urlParams.globalFilter === "object" &&
"filters" in urlParams.globalFilter
return urlParams.globalFilter
// Otherwise return search string
}, [urlParams.globalFilter, urlParams.search])
// Convert URL state to TanStack Table format
const pagination: PaginationState = useMemo(
pageIndex: urlParams.pageIndex,
pageSize: urlParams.pageSize,
[urlParams.pageIndex, urlParams.pageSize],
// Parse sorting from URL
const sorting: SortingState = useMemo(() => {
return urlParams.sort || []
* Convert a URL-stored filter entry back into a TanStack `ColumnFilter`.
* URL entries are `{id, value}` objects (see `serializeColumnFiltersForUrl`).
* The `value` is either a raw TanStack value (tuple/array/string/boolean) or
* an ExtendedColumnFilter sans `filterId`. Either way, TanStack just needs
* `{id, value}` — it doesn't care what `value` is.
const urlEntryToColumnFilter = useCallback(
(entry: unknown): { id: string; value: unknown } | null => {
if (!entry || typeof entry !== "object") return null
const e = entry as { id?: string; value?: unknown }
// Entries written by the new serializer carry top-level `id` + `value`.
if (typeof e.id === "string") return { id: e.id, value: e.value }
// Legacy / filter-menu format: the entry IS an ExtendedColumnFilter.
if ("operator" in (e as Record<string, unknown>)) {
const f = e as ExtendedColumnFilter<Product>
return { id: f.id, value: f }
// Standard mode filters - convert from URL format to ColumnFiltersState
const standardColumnFilters: ColumnFiltersState = useMemo(() => {
// If globalFilter has OR/mixed filters, keep columnFilters EMPTY
typeof globalFilter === "object" &&
"filters" in globalFilter &&
filterMode === "standard"
return [] // Empty - filters are in globalFilter
return (urlParams.filters as unknown[])
.map(urlEntryToColumnFilter)
.filter((f): f is { id: string; value: unknown } => f !== null)
}, [urlParams.filters, globalFilter, filterMode, urlEntryToColumnFilter])
// Inline mode filters - convert from URL format to ColumnFiltersState
const inlineColumnFilters: ColumnFiltersState = useMemo(() => {
// If globalFilter has OR/mixed filters, keep columnFilters EMPTY
typeof globalFilter === "object" &&
"filters" in globalFilter &&
return [] // Empty - filters are in globalFilter
return (urlParams.inlineFilters as unknown[])
.map(urlEntryToColumnFilter)
.filter((f): f is { id: string; value: unknown } => f !== null)
// Column pinning state from URL
const columnPinning: ColumnPinningState = useMemo(() => {
return urlParams.pin || { left: [], right: [] }
// 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)
// Column visibility from URL
const columnVisibility = urlParams.columnVisibility
// 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 and total 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.
// - Slider columns receive `range={facets.range[col]}` so the slider can
// still be widened back after an active filter narrows the row set.
const dynamicColumns = useMemo(() => {
* Cross-filter narrowing — server's facet for column X excludes X's own
* filter, so its values are exactly the cross-filter survivors. We just
* merge counts onto the full static label list; the faceted filter's
* default `count === 0 → hide` rule handles the narrowing. So:
* - Column being filtered: facet still includes every value → user can
* pivot to any other option.
* - Other columns: facet only includes reachable values → impossible
* options (e.g. Brand=Nike while Category=Electronics) get count 0
* and disappear automatically.
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(() => {
// 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>) => {
// Get current pagination from URL params to ensure we have latest state
const currentPagination: PaginationState = {
pageIndex: urlParams.pageIndex,
pageSize: urlParams.pageSize,
typeof updater === "function" ? updater(currentPagination) : updater
pageIndex: newPagination.pageIndex,
pageSize: newPagination.pageSize,
[urlParams.pageIndex, urlParams.pageSize, setUrlParams],
const handleSortingChange = useCallback(
(updater: Updater<SortingState>) => {
typeof updater === "function" ? updater(sorting) : updater
void setUrlParams({ sort: newSorting.length > 0 ? newSorting : null })
// Handlers for column pinning
const handleColumnPinningChange = useCallback(
(updater: Updater<ColumnPinningState>) => {
typeof updater === "function" ? updater(columnPinning) : updater
void setUrlParams({ pin: newPinning })
[columnPinning, setUrlParams],
* Serialize a TanStack columnFilters array for URL storage.
* WHY: Column-header filters (slider, date, faceted, etc.) write raw
* TanStack values via `column.setFilterValue(rawValue)`, while the filter
* menu writes an `ExtendedColumnFilter` object into `filter.value`. The old
* handler unwrapped `filter.value` and pushed it through `serializeFiltersForUrl`,
* which destructures arrays into `{0: x, 1: y}` objects — that's why slider
* drags kept appending garbage to the URL.
* IMPACT: Round-trip now lossless for both shapes. Slider/date/faceted
* filters update the URL once per change instead of accumulating entries.
* WHAT: Writes the full TanStack `{id, value}` shape. If `value` is itself
* an `ExtendedColumnFilter`, strip its `filterId` for URL brevity (regenerated
* on read via `normalizeFiltersFromUrl`).
const serializeColumnFiltersForUrl = useCallback(
(newFilters: ColumnFiltersState) => {
return newFilters.map(filter => {
const value = filter.value
typeof value === "object" &&
"filterId" in (value as Record<string, unknown>)
// Filter-menu ExtendedColumnFilter — strip filterId.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { filterId, ...rest } = value as ExtendedColumnFilter<Product>
return { id: filter.id, value: rest }
return { id: filter.id, value }
}) as unknown as ExtendedColumnFilter<Product>[]
// Handlers for filters (standard mode)
const handleStandardColumnFiltersChange = useCallback(
(updater: Updater<ColumnFiltersState>) => {
typeof updater === "function" ? updater(standardColumnFilters) : updater
filters: serializeColumnFiltersForUrl(newFilters),
[standardColumnFilters, setUrlParams, serializeColumnFiltersForUrl],
// Handlers for filters (inline mode)
const handleInlineColumnFiltersChange = useCallback(
(updater: Updater<ColumnFiltersState>) => {
typeof updater === "function" ? updater(inlineColumnFilters) : updater
inlineFilters: serializeColumnFiltersForUrl(newFilters),
[inlineColumnFilters, setUrlParams, serializeColumnFiltersForUrl],
// 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 setUrlParams
prevGlobalFilterRef.current = value
if (typeof value === "string") {
// Simple search string - only set search param
// Keep globalFilter independent - both can coexist
search: value || null, // null removes from URL if empty
// OR filter object - store in globalFilter
// Keep search param independent - both can coexist
// Exclude filterId from filters to keep URLs shorter
const filterObj = value as {
filters: ExtendedColumnFilter<Product>[]
const serializedFilters = serializeFiltersForUrl(
) as ExtendedColumnFilter<Product>[]
filters: serializedFilters,
joinOperator: filterObj.joinOperator,
// Don't clear search - it's independent from globalFilter
// Handlers for column visibility
const handleColumnVisibilityChange = useCallback(
(updater: Updater<VisibilityState>) => {
typeof updater === "function"
? updater(urlParams.columnVisibility)
void setUrlParams({ columnVisibility: newVisibility })
[urlParams.columnVisibility, setUrlParams],
// Extract ExtendedColumnFilter list for the filter menu UI.
// URL entries are `{id, value}` shape; only entries whose `value` is itself
// an ExtendedColumnFilter (has `operator`) belong to the filter menu — raw
// column-header values (slider tuples, etc.) are filtered out here.
const currentStandardFilters = useMemo(() => {
typeof globalFilter === "object" &&
"filters" in globalFilter &&
filterMode === "standard"
const filterObj = globalFilter as {
filters: ExtendedColumnFilter<Product>[]
return filterObj.filters || []
return ((urlParams.filters as unknown[]) || [])
.map(entry => extractExtendedFilter(entry))
.filter((f): f is ExtendedColumnFilter<Product> => f !== null)
}, [urlParams.filters, 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],
// Same as currentStandardFilters, but for the inline filter mode.
const currentInlineFilters = useMemo(() => {
typeof globalFilter === "object" &&
"filters" in globalFilter &&
const filterObj = globalFilter as {
filters: ExtendedColumnFilter<Product>[]
return filterObj.filters || []
return ((urlParams.inlineFilters as unknown[]) || [])
.map(entry => extractExtendedFilter(entry))
.filter((f): f is ExtendedColumnFilter<Product> => f !== null)
}, [urlParams.inlineFilters, 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),
// Prettify query string for display - decode and format JSON values
const prettifiedQueryString = useMemo(
() => formatQueryString(urlParams, tableStateUrlKeys),
// Direct filter change handlers - sync filter UI changes directly to URL
const handleStandardFiltersChange = useCallback(
(filters: ExtendedColumnFilter<Product>[] | null) => {
// When clearing filters (null or empty array), also clear globalFilter and search
if (!filters || filters.length === 0) {
globalFilter: null, // null removes from URL
search: null, // null removes from URL
// Use core utility to process filters and determine routing
const result = processFiltersForLogic(filters)
// Exclude filterId from URL to keep URLs shorter
const urlFilters = serializeFiltersForUrl(
) as ExtendedColumnFilter<Product>[]
if (result.shouldUseGlobalFilter) {
// Use globalFilter for OR/MIXED logic
joinOperator: result.joinOperator,
// Use filters param for AND logic
// Only clear globalFilter if it exists in URL (don't set it if it doesn't exist)
const params: Record<string, unknown> = {
// Check if globalFilter exists in URL (not just default value)
urlParams.globalFilter !== null &&
typeof urlParams.globalFilter === "object" &&
"filters" in urlParams.globalFilter
params.globalFilter = null
void setUrlParams(params)
[setUrlParams, urlParams.globalFilter],
const handleInlineFiltersChange = useCallback(
(filters: ExtendedColumnFilter<Product>[]) => {
// When clearing filters (empty array), also clear globalFilter and search
if (filters.length === 0) {
globalFilter: null, // null removes from URL
search: null, // null removes from URL
// Use core utility to process filters and determine routing
const result = processFiltersForLogic(filters)
// Exclude filterId from URL to keep URLs shorter
const urlFilters = serializeFiltersForUrl(
) as ExtendedColumnFilter<Product>[]
if (result.shouldUseGlobalFilter) {
// Use globalFilter for OR/MIXED logic
joinOperator: result.joinOperator,
// Don't clear search - it's independent from globalFilter
// Use inlineFilters param for AND logic
// Only clear globalFilter if it exists in URL (don't set it if it doesn't exist)
const params: Record<string, unknown> = {
inlineFilters: urlFilters,
// Check if globalFilter exists in URL (not just default value)
urlParams.globalFilter !== null &&
typeof urlParams.globalFilter === "object" &&
"filters" in urlParams.globalFilter
params.globalFilter = null
void setUrlParams(params)
[setUrlParams, urlParams.globalFilter],
// 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
// Switching to inline: clear standard filters
<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}
onColumnPinningChange={handleColumnPinningChange}
filters={normalizedStandardFilters}
onFiltersChange={handleStandardFiltersChange}
search={urlParams.search}
onSearchChange={value => {
search: value || null, // null removes from URL if empty
<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}
onColumnPinningChange={handleColumnPinningChange}
filters={normalizedInlineFilters}
onFiltersChange={handleInlineFiltersChange}
search={urlParams.search}
onSearchChange={value => {
search: value || null, // null removes from URL if empty
<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 (URL Synced)</CardTitle>
All state is persisted in the URL and survives page refreshes. Data
is fetched from a mocked API with delays using TanStack Query.
<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 flex-col gap-1">
<span className="font-medium">Current URL:</span>
<code className="overflow-wrap-anywhere block rounded bg-muted px-2 py-1 font-mono text-xs break-all whitespace-pre-wrap">
<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)}
<strong>URL Filters (AND logic):</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(urlParams.filters, null, 2)}
<strong>Inline Filters:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(urlParams.inlineFilters, null, 2)}
<strong>Search Query:</strong>
<pre className="mt-1 overflow-auto rounded bg-muted p-2">
{JSON.stringify(urlParams.search, 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> Try adding filters, sorting, or changing
pages, then copy the URL and paste it in a new tab. All your table
state will be preserved! The data is fetched server-side with
* Main component wrapped in QueryClientProvider and NuqsAdapter
* This example includes both QueryClientProvider and NuqsAdapter at the component level
* since it's a standalone example.
* For production apps, it's recommended to add these providers 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 />