* Drizzle ORM Server-Side Table — live, mocked demo
* A browser-runnable companion to the Drizzle ORM guide. The query-building
* code (`buildWhere`, `buildOrderBy`, `computeFacets`) is written exactly
* like the guide's real Drizzle code — same operators, same dispatch, same
* structure. The only difference: the operators here are a ~80-line mock of
* drizzle-orm's API that compiles each condition to BOTH
* 1. a row predicate, so the query executes against an in-memory array
* right in your browser, and
* 2. SQL text with bound params, so you can watch the exact statement a
* real Postgres would receive update live as you filter and sort.
* To go real: install drizzle-orm, replace the "MOCK DRIZZLE" section with
* `import { and, or, eq, ilike, ... } from "drizzle-orm"` and your pgTable
* schema, and move fetchProducts behind an API route — the builder code
* stays the same. Full walkthrough: the Drizzle ORM guide in the docs.
* Prerequisites: npm install @tanstack/react-query
import { useCallback, useMemo, useState } from "react"
} 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 { DataTableFacetedFilter } from "@/components/niko-table/components/data-table-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 { 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"
import { normalizeFiltersFromUrl } from "@/components/niko-table/filters/table-filter-menu"
import { useDebounce } from "@/components/niko-table/hooks/use-debounce"
} from "@/components/niko-table/types"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
} from "@/components/ui/card"
import { AlertCircle, Database, Loader2, SearchX } from "lucide-react"
/* -------------------------------------------------------------------------
* 1. The wire contract — identical to the Server-Side Table example
* ---------------------------------------------------------------------- */
columnFilters: ColumnFiltersState
type ProductQueryResult = {
select: Record<string, Array<{ value: string; count: number }>>
range: Record<string, [number, number]>
/** Demo-only: the SQL a real Postgres would receive for this query. */
debug: { sql: string; params: unknown[] }
/* -------------------------------------------------------------------------
* 2. MOCK DRIZZLE — delete this section in a real app
* A miniature stand-in for drizzle-orm's condition builders with the same
* call signatures. Each operator compiles to SQL text + bound params AND a
* predicate so the query can run against the in-memory rows below. With
* real Drizzle you'd write instead:
* import { and, or, not, eq, ne, ilike, notIlike, gt, gte, lt, lte,
* between, inArray, notInArray, isNull, asc, desc } from "drizzle-orm"
* ---------------------------------------------------------------------- */
/** A queryable column: SQL name + accessor into the row object. */
type Col = { name: string; key: keyof Product }
/** A compiled condition: SQL text (with `?` placeholders) + predicate. */
test: (row: Product) => boolean
type Order = { sql: string; cmp: (a: Product, b: Product) => number }
const num = (v: unknown): number =>
v instanceof Date ? v.getTime() : Number(v)
const lower = (v: unknown): string => String(v).toLowerCase()
const eq = (c: Col, v: unknown): Cond => ({
test: r => lower(r[c.key]) === lower(v),
const ne = (c: Col, v: unknown): Cond => ({
test: r => lower(r[c.key]) !== lower(v),
const ilike = (c: Col, v: string): Cond => ({
sql: `${c.name} ILIKE ?`,
test: r => lower(r[c.key]).includes(lower(v).replaceAll("%", "")),
const notIlike = (c: Col, v: string): Cond => ({
sql: `${c.name} NOT ILIKE ?`,
test: r => !lower(r[c.key]).includes(lower(v).replaceAll("%", "")),
const gt = (c: Col, v: unknown): Cond => ({
test: r => num(r[c.key]) > num(v),
const gte = (c: Col, v: unknown): Cond => ({
test: r => num(r[c.key]) >= num(v),
const lt = (c: Col, v: unknown): Cond => ({
test: r => num(r[c.key]) < num(v),
const lte = (c: Col, v: unknown): Cond => ({
test: r => num(r[c.key]) <= num(v),
const between = (c: Col, lo: unknown, hi: unknown): Cond => ({
sql: `${c.name} BETWEEN ? AND ?`,
test: r => num(r[c.key]) >= num(lo) && num(r[c.key]) <= num(hi),
const inArray = (c: Col, values: unknown[]): Cond => ({
sql: `${c.name} IN (${values.map(() => "?").join(", ")})`,
test: r => values.some(v => lower(r[c.key]) === lower(v)),
const notInArray = (c: Col, values: unknown[]): Cond => ({
sql: `${c.name} NOT IN (${values.map(() => "?").join(", ")})`,
test: r => !values.some(v => lower(r[c.key]) === lower(v)),
const isNull = (c: Col): Cond => ({
sql: `${c.name} IS NULL`,
test: r => r[c.key] === null || r[c.key] === undefined,
const not = (cond: Cond): Cond => ({
sql: `NOT (${cond.sql})`,
test: r => !cond.test(r),
const and = (...conds: (Cond | undefined)[]): Cond | undefined =>
const or = (...conds: (Cond | undefined)[]): Cond | undefined =>
conds: (Cond | undefined)[],
const defined = conds.filter((c): c is Cond => c !== undefined)
if (defined.length === 0) return undefined
if (defined.length === 1) return defined[0]
sql: `(${defined.map(c => c.sql).join(` ${op} `)})`,
params: defined.flatMap(c => c.params),
? r => defined.every(c => c.test(r))
: r => defined.some(c => c.test(r)),
const asc = (c: Col): Order => ({
cmp: (a, b) => (num(a[c.key]) < num(b[c.key]) ? -1 : 1),
const desc = (c: Col): Order => ({
cmp: (a, b) => (num(a[c.key]) > num(b[c.key]) ? -1 : 1),
// Text columns compare as strings, not numbers
(a: Product, b: Product): number =>
dir * String(a[c.key]).localeCompare(String(b[c.key]))
/** Render `?` placeholders as $1..$n for display. */
function numberParams(sql: string): string {
return sql.replace(/\?/g, () => `$${++i}`)
/* -------------------------------------------------------------------------
* 3. Schema + column mapping — mirrors db/schema.ts in the guide
* ---------------------------------------------------------------------- */
id: { name: "id", key: "id" },
name: { name: "name", key: "name" },
category: { name: "category", key: "category" },
brand: { name: "brand", key: "brand" },
price: { name: "price", key: "price" },
stock: { name: "stock", key: "stock" },
rating: { name: "rating", key: "rating" },
inStock: { name: "in_stock", key: "inStock" },
releaseDate: { name: "release_date", key: "releaseDate" },
} satisfies Record<string, Col>
/** UI column id → Drizzle column. Unknown ids fall out safely. */
const columnMap: Record<string, Col> = {
category: products.category,
inStock: products.inStock,
releaseDate: products.releaseDate,
const TEXT_COLUMNS = new Set(["name", "category", "brand", "id"])
/** Columns the global search input scans. */
const searchableColumns = [products.name, products.category, products.brand]
/* -------------------------------------------------------------------------
* 4. Filters → WHERE / ORDER BY — this code is the guide's code, verbatim
* ---------------------------------------------------------------------- */
type MenuFilter = ExtendedColumnFilter<Product>
* Comparison operators bind their value straight into the query. A non-scalar
* value (an accidentally-nested object/array) would otherwise reach the
* builder as-is — with a real driver that's a malformed bound parameter, not
* a graceful no-op — so guard to the shapes a column value can actually be.
function isComparableScalar(value: unknown): boolean {
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
/** One advanced-filter-menu rule → SQL condition. */
function menuFilterToSql(filter: MenuFilter): Cond | undefined {
const column = columnMap[filter.id]
if (!column) return undefined
const value = filter.value
filter.operator !== FILTER_OPERATORS.EMPTY &&
filter.operator !== FILTER_OPERATORS.NOT_EMPTY
if (needsValue && (value === undefined || value === null || value === ""))
switch (filter.operator) {
case FILTER_OPERATORS.ILIKE:
return ilike(column, `%${String(value)}%`)
case FILTER_OPERATORS.NOT_ILIKE:
return notIlike(column, `%${String(value)}%`)
case FILTER_OPERATORS.EQ:
return isComparableScalar(value) ? eq(column, value) : undefined
case FILTER_OPERATORS.NEQ:
return isComparableScalar(value) ? ne(column, value) : undefined
case FILTER_OPERATORS.GT:
return isComparableScalar(value) ? gt(column, value) : undefined
case FILTER_OPERATORS.GTE:
return isComparableScalar(value) ? gte(column, value) : undefined
case FILTER_OPERATORS.LT:
return isComparableScalar(value) ? lt(column, value) : undefined
case FILTER_OPERATORS.LTE:
return isComparableScalar(value) ? lte(column, value) : undefined
case FILTER_OPERATORS.IN:
return Array.isArray(value) ? inArray(column, value) : undefined
case FILTER_OPERATORS.NOT_IN:
return Array.isArray(value) ? notInArray(column, value) : undefined
case FILTER_OPERATORS.EMPTY:
return or(isNull(column), eq(column, ""))
case FILTER_OPERATORS.NOT_EMPTY:
return not(or(isNull(column), eq(column, ""))!)
/** One columnFilters entry (widget value OR menu filter) → SQL condition. */
function columnFilterToSql(id: string, value: unknown): Cond | undefined {
// advanced filter menu entries carry an `operator`
typeof value === "object" &&
return menuFilterToSql(value as MenuFilter)
const column = columnMap[id]
if (!column || value == null || value === "") return undefined
// single date filter: bare ms timestamp → match the calendar day
if (typeof value === "number" && id === "releaseDate") {
const day = new Date(value)
const next = new Date(day.getTime() + 24 * 60 * 60 * 1000)
return and(gte(column, day), lt(column, next))
// [min, max] tuple from slider / date-range filters (null = open-ended)
!value.every(v => typeof v === "string")
const [min, max] = value as [unknown, unknown]
const lo = min == null ? undefined : toColumnValue(id, min)
const hi = max == null ? undefined : toColumnValue(id, max)
if (lo != null && hi != null) return between(column, lo, hi)
if (lo != null) return gte(column, lo)
if (hi != null) return lte(column, hi)
// string[] from faceted multi-select
if (Array.isArray(value)) {
return value.length > 0 ? inArray(column, value) : undefined
if (typeof value === "boolean") return eq(column, value)
return ilike(column, `%${String(value)}%`)
/** Timestamp columns receive Date objects; everything else passes through. */
function toColumnValue(id: string, value: unknown): unknown {
return id === "releaseDate" ? new Date(Number(value)) : value
* All conditions for a query. `excludeColumnId` powers facet computation:
* a column's facet is computed under every filter EXCEPT its own.
excludeColumnId?: string,
const conditions: (Cond | undefined)[] = []
// global text search across the searchable columns
if (typeof query.search === "string" && query.search) {
const term = query.search
conditions.push(or(...searchableColumns.map(c => ilike(c, `%${term}%`))))
// OR / MIXED logic from the advanced filter menu (rides in `search`)
typeof query.search === "object" &&
"filters" in query.search
const filterObj = query.search as { filters: MenuFilter[] }
const orConditions = (filterObj.filters ?? [])
.filter(f => f.id !== excludeColumnId)
conditions.push(or(...orConditions))
// AND logic: every columnFilters entry must match
for (const filter of query.columnFilters) {
if (filter.id === excludeColumnId) continue
conditions.push(columnFilterToSql(filter.id, filter.value))
return and(...conditions)
/** query.sorting → ORDER BY. */
function buildOrderBy(query: ProductQuery): Order[] {
.filter(s => columnMap[s.id])
const column = columnMap[s.id]
const order = s.desc ? desc(column) : asc(column)
// mock-only: text columns need string comparison for the predicate
return TEXT_COLUMNS.has(s.id)
? { ...order, cmp: strCmp(column, s.desc ? -1 : 1) }
/* -------------------------------------------------------------------------
* 5. MOCK DATABASE + API — with real Drizzle this is an API route
* executing db.select().from(products).where(...).orderBy(...)
* ---------------------------------------------------------------------- */
const baseProducts: 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),
// The "database" — 15 base products expanded to 500 rows.
const productRows: Product[] = Array.from({ length: 500 }, (_, i) => {
const base = baseProducts[i % baseProducts.length]
const variation = Math.floor(i / baseProducts.length)
id: `${base.id}-${variation}`,
name: variation > 0 ? `${base.name} (${variation + 1})` : base.name,
price: base.price + variation * 10,
stock: Math.max(0, base.stock - variation * 5),
inStock: base.stock - variation * 5 > 0,
base.releaseDate.getTime() - variation * 24 * 60 * 60 * 1000,
const FACET_SELECT_COLUMNS = ["category", "brand"] as const
const FACET_RANGE_COLUMNS = ["price"] as const
/** Grouped counts / min-max with the column's own filter excluded. */
function computeFacets(query: ProductQuery): ProductQueryResult["facets"] {
const select: ProductQueryResult["facets"]["select"] = {}
const range: ProductQueryResult["facets"]["range"] = {}
for (const col of FACET_SELECT_COLUMNS) {
const where = buildWhere(query, col)
const rows = where ? productRows.filter(where.test) : productRows
const counts = new Map<string, number>()
for (const row of rows) {
const v = String(row[col])
counts.set(v, (counts.get(v) ?? 0) + 1)
select[col] = [...counts.entries()]
.map(([value, count]) => ({ value, count }))
.sort((a, b) => a.value.localeCompare(b.value))
for (const col of FACET_RANGE_COLUMNS) {
const where = buildWhere(query, col)
const rows = where ? productRows.filter(where.test) : productRows
if (rows.length === 0) continue
const values = rows.map(r => Number(r[col]))
range[col] = [Math.min(...values), Math.max(...values)]
* The fake API route. With real Drizzle this is:
* const [data, [{ total }], facets] = await Promise.all([
* db.select().from(products).where(where)
* .orderBy(...buildOrderBy(query))
* .limit(query.pageSize).offset(query.page * query.pageSize),
* db.select({ total: count() }).from(products).where(where),
): Promise<ProductQueryResult> {
return new Promise(resolve => {
const where = buildWhere(query)
const orderBy = buildOrderBy(query)
let rows = where ? productRows.filter(where.test) : [...productRows]
if (orderBy.length > 0) {
rows = [...rows].sort((a, b) => {
for (const order of orderBy) {
const result = order.cmp(a, b)
if (result !== 0) return result
const total = rows.length
const offset = query.page * query.pageSize
const data = rows.slice(offset, offset + query.pageSize)
// the statement a real Postgres would receive
const sql = numberParams(
"SELECT * FROM products",
where ? `WHERE ${where.sql}` : "",
? `ORDER BY ${orderBy.map(o => o.sql).join(", ")}`
`LIMIT ${query.pageSize} OFFSET ${offset}`,
facets: computeFacets(query),
debug: { sql, params: where?.params ?? [] },
/* -------------------------------------------------------------------------
* 6. Columns — same as the Server-Side Table example
* ---------------------------------------------------------------------- */
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" },
type ProductFacets = ProductQueryResult["facets"]
type FacetOption = { label: string; value: string; count?: number }
/** Merge server facet counts onto a static option list (count 0 when absent). */
staticOpts: FacetOption[],
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 }))
/** Selected values for a column, tolerating the menu/faceted `{ value }` shape. */
function selectedFacetValues(
columnFilters: ColumnFiltersState,
const entry = columnFilters.find(f => f.id === columnId)
if (!entry) return new Set()
let raw: unknown = entry.value
if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in raw) {
raw = (raw as { value: unknown }).value
if (raw == null || raw === "") return new Set()
const values = Array.isArray(raw) ? raw : [raw]
return new Set(values.filter(v => v != null && v !== "").map(String))
* Toolbar facets carry server counts and must narrow to values still present
* in the results — like the header funnels do. The client only holds the
* current page, so narrowing happens here from the server counts: drop count-0
* options, but keep any currently-selected value so it stays visible and
function narrowFacetOptions(
columnFilters: ColumnFiltersState,
const selected = selectedFacetValues(columnFilters, columnId)
return opts.filter(o => o.count !== 0 || selected.has(o.value))
function buildColumns(facets?: ProductFacets): DataTableColumnDef<Product>[] {
const categoryOpts = mergeCounts(categoryOptions, facets?.select.category)
const brandOpts = mergeCounts(brandOptions, facets?.select.brand)
const priceRange = facets?.range.price
<DataTableColumnSortMenu />
variant: FILTER_VARIANTS.TEXT,
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} />
<DataTableColumnFacetedFilterMenu options={categoryOpts} />
variant: FILTER_VARIANTS.SELECT,
options: categoryOptions,
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 options={brandOpts} />
variant: FILTER_VARIANTS.SELECT,
enableColumnFilter: true,
<DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} />
<DataTableColumnSliderFilterMenu range={priceRange} />
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,
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 aria-hidden="true">★</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,
/* -------------------------------------------------------------------------
* 7. The table — same wiring as the Server-Side Table example
* ---------------------------------------------------------------------- */
/** True when a columnFilters entry value came from the advanced filter menu. */
function isMenuFilterValue(
): value is ExtendedColumnFilter<Product> {
typeof value === "object" &&
function DrizzleTableContent() {
const [pagination, setPagination] = useState<PaginationState>({
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [globalFilter, setGlobalFilter] = useState<string | object>("")
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
// Debounce search + columnFilters as ONE snapshot, not separately. A single
// advanced-menu action can write both at once; debouncing them on their own
// clocks would let the undebounced field reach the request slightly ahead
// of the other, transiently mixing a new filter with a stale one (and the
// reverse on clear). Sorting/pagination stay undebounced.
const debouncedInput = useDebounce(
() => ({ search: globalFilter, columnFilters }),
[globalFilter, columnFilters],
debouncedInput.columnFilters,
page: pagination.pageIndex,
pageSize: pagination.pageSize,
search: debouncedInput.search,
columnFilters: debouncedInput.columnFilters,
placeholderData: keepPreviousData,
const data = queryData?.data ?? []
const totalCount = queryData?.total ?? 0
totalCount > 0 ? Math.ceil(totalCount / pagination.pageSize) : 1
() => buildColumns(queryData?.facets),
// Toolbar facet options: server counts merged in, then narrowed to values
// still present in the results (count-0 dropped, current selection pinned).
const facets = queryData?.facets
const categoryFacetOptions = useMemo(
mergeCounts(categoryOptions, facets?.select.category),
[facets?.select.category, columnFilters],
const brandFacetOptions = useMemo(
mergeCounts(brandOptions, facets?.select.brand),
[facets?.select.brand, columnFilters],
queryError instanceof Error
const handleSortingChange = useCallback((updater: Updater<SortingState>) => {
typeof updater === "function" ? updater(prev) : updater,
setPagination(prev => ({ ...prev, pageIndex: 0 }))
const handleColumnFiltersChange = useCallback(
(updater: Updater<ColumnFiltersState>) => {
typeof updater === "function" ? updater(prev) : updater,
setPagination(prev => ({ ...prev, pageIndex: 0 }))
const handleGlobalFilterChange = useCallback((value: string | object) => {
setGlobalFilter(prev => {
if (value === "" && typeof prev === "object" && prev !== null) {
setPagination(prev => ({ ...prev, pageIndex: 0 }))
const menuFilters = useMemo(() => {
typeof globalFilter === "object" &&
"filters" in globalFilter
const filterObj = globalFilter as {
filters: ExtendedColumnFilter<Product>[]
return normalizeFiltersFromUrl(filterObj.filters ?? [])
return normalizeFiltersFromUrl(
columnFilters.map(cf => cf.value).filter(isMenuFilterValue),
}, [globalFilter, columnFilters])
const handleMenuFiltersChange = useCallback(
(filters: ExtendedColumnFilter<Product>[] | null) => {
const next = filters ?? []
setColumnFilters(prev => prev.filter(f => !isMenuFilterValue(f.value)))
setGlobalFilter(prev => (typeof prev === "object" ? "" : prev))
const result = processFiltersForLogic(next)
if (result.shouldUseGlobalFilter) {
prev.filter(f => !isMenuFilterValue(f.value)),
filters: result.processedFilters,
joinOperator: result.joinOperator,
setColumnFilters(prev => [
...prev.filter(f => !isMenuFilterValue(f.value)),
...result.processedFilters.map(filter => ({
setGlobalFilter(prev => (typeof prev === "object" ? "" : prev))
setPagination(prev => ({ ...prev, pageIndex: 0 }))
const resetAllState = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: 10 })
<div className="w-full space-y-4">
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 pt-6">
<AlertCircle className="size-5 text-destructive" />
<p className="text-sm font-medium text-destructive">
<p className="text-xs text-muted-foreground">{error}</p>
onClick={() => refetch()}
onPaginationChange={setPagination}
onSortingChange={handleSortingChange}
onColumnFiltersChange={handleColumnFiltersChange}
onGlobalFilterChange={handleGlobalFilterChange}
onColumnVisibilityChange={setColumnVisibility}
<DataTableToolbarSection>
<DataTableToolbarSection className="px-0">
<DataTableSearchFilter placeholder="Search products..." />
</DataTableToolbarSection>
{/* Facets on the left, sort/filter pushed to the right of the same
row (ml-auto). Wraps to a new line on narrow widths. */}
<DataTableToolbarSection className="flex-wrap px-0">
{/* Toolbar facets read server-computed counts. dynamicCounts /
limitToFilteredRows are off because the client holds only the
current page — counts and narrowing come from the server. */}
options={categoryFacetOptions}
limitToFilteredRows={false}
options={brandFacetOptions}
limitToFilteredRows={false}
{isPlaceholderData && isFetching && (
className="ml-auto size-4 animate-spin text-muted-foreground"
aria-label="Loading new results"
<DataTableSortMenu className="ml-auto" />
onFiltersChange={handleMenuFiltersChange}
</DataTableToolbarSection>
</DataTableToolbarSection>
{/* maxHeight keeps large page sizes scrollable instead of growing the page */}
<DataTable maxHeight={500}>
<DataTableSkeleton rows={pagination.pageSize} />
<Database className="size-12" />
<DataTableEmptyTitle>No products found</DataTableEmptyTitle>
<DataTableEmptyDescription>
There are no products to display at this time.
</DataTableEmptyDescription>
<DataTableEmptyFilteredMessage>
<SearchX className="size-12" />
<DataTableEmptyTitle>No matches found</DataTableEmptyTitle>
<DataTableEmptyDescription>
Try adjusting your filters or search to find what you're
</DataTableEmptyDescription>
</DataTableEmptyFilteredMessage>
{/* Demo-only: the SQL a real Postgres would receive for this view */}
<CardTitle className="flex items-center gap-2">
<Database className="size-4" aria-hidden="true" />
Filter, search, and sort the table — this is the statement the
guide's Drizzle code produces for the current view (the demo
executes it with a mocked query builder in your browser).
<Button variant="outline" size="sm" onClick={resetAllState}>
<CardContent className="space-y-3 text-xs">
<pre className="overflow-auto rounded bg-muted p-2">
{queryData?.debug.sql ?? "Loading..."}
<span className="font-medium">Params: </span>
<code className="break-all">
{JSON.stringify(queryData?.debug.params ?? [])}
<div className="flex justify-between text-muted-foreground">
{totalCount} matching rows, showing {data.length}
<span>{isFetching ? "Executing..." : "Done"}</span>
* Self-contained wrapper. In a real app create the QueryClient once at your
* app root and wrap the layout with QueryClientProvider instead.
export default function DrizzleStateTableExample() {
const [queryClient] = useState(
refetchOnWindowFocus: false,
<QueryClientProvider client={queryClient}>