f-ui
Components

Data Table

Schema-driven TanStack Table with toolbar, filters, URL state, batch actions, and JSON Logic — published on the plus f-ui registry.

The Data Table module is built on a region architecture: useDataTable() produces a DataTableHandle, <DataTableRoot> provides context, and named region components (DataTableToolbar, DataTableBody, DataTablePagination, …) compose bespoke layouts. <DataTable> is the turnkey recipe for the common case — it assembles those regions with curated props (filtering, paginationMode, chrome, …).

Author tables with a declarative TableSchema (defineTableSchema), pass a static data array or a ListQueryAdapter, and opt into capabilities via features. Filter surfaces are selected with filtering; pagination UX with paginationMode. Hooks such as useTableSearchParams sync sort, filters, and paging to the URL (nuqs). Default density is normal; use defaultDensity="compact" for tighter rows.

Built-in UI strings (toolbar, filter panel, operators, pagination, …) resolve through useDataTableI18n() and FuiI18nProvider—not useDataTable options. See Internationalization for wiring and reactive locale when users switch language without a reload.

Folder layout for maintainers is described in the data-table README beside the source.

Installing

This item is on the plus registry (registry.plus.json), not the open index. Configure @f-ui-plus, set FUI_PLUS_REGISTRY_TOKEN, and verify access as described in Installation — Plus Registry, then run:

FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/data-table
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/data-table
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/data-table
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/data-table

Prerequisites / Peers

  • nuqs — URL state for filters, sort, and pagination params. Wrap your app with NuqsAdapter from your router's nuqs adapter. Only required if you use useTableSearchParams; the in-memory useLocalTableParams variant has no router dependency.
  • @tanstack/react-table — table core (declared in the registry item).
  • @tanstack/react-query — the table always loads rows through a ListQueryAdapter. Wrap your app with <QueryClientProvider>. Sync adapters (createInMemoryListAdapter, createPrePagedAdapter, and sync createHybridListAdapter sources) use initialData so the first frame is not a loading state.
  • shadcn components resolved via registryDependencies (button, select, dialog, sheet, pagination, context-menu, etc.).

URL ⇄ wire naming boundary

The data-table uses camelCase for URL parameters (?page=2&pageSize=50&sortBy=createdAt&sortOrder=desc) and JSON-Logic var references. Adapters are responsible for translating to whatever wire format their backend expects — snake_case for REST, GraphQL field names, etc. URL state stays camelCase regardless of how each adapter chooses to talk to its server.

Region Architecture

Provider + named regions — no compound namespacing (DataTable.Toolbar). Mount only the regions you need; skip empty chrome bands.

LayerRole
useDataTable()Opt-in orchestrator → DataTableHandle
<DataTableRoot dataTable={handle}>Shell, context, fillHeight / stickyHeader
Named regionsDataTableToolbar, DataTableViewTabs, DataTableBody, DataTablePagination, DataTableLoadMore, …
<DataTable>Recipe that assembles regions for the 80% case

Every region accepts an optional dataTable prop and resolves the same handle from context when nested under <DataTableRoot>. For bespoke layouts, call useDataTable() yourself and compose regions directly.

Compose named regions instead of the recipe. Swap DataTablePagination for DataTableLoadMore (cursor) or add DataTableViewTabs above the body for preset tabs.

ORD-1001Acme CorpConfirmed$2,499.00
ORD-1002Globex IncShipped$849.50
ORD-1003InitechDraft$120.00
ORD-1004Umbrella LLCDelivered$3,200.00
ORD-1005Soylent CorpCancelled$45.99
ORD-1006Wayne EnterprisesConfirmed$18,750.00
ORD-1007Stark IndustriesShipped$5,600.00
ORD-1008OscorpDraft$299.99
1–8 of 20
Rows per page
"use client";

import { useMemo } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { DataTableBody } from "@/components/f-ui/data-table/data-table-parts/data-table-body";
import { DataTablePagination } from "@/components/f-ui/data-table/data-table-parts/data-table-pagination";
import { DataTableRoot } from "@/components/f-ui/data-table/data-table-parts/data-table-root";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { useDataTable } from "@/components/f-ui/data-table/use-data-table";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";

const TABLE_CODE = "docs-dt-bespoke-regions";

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } },
});

function DataTableBespokeRegionsDemoInner() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 110,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  const dt = useDataTable<Order>({
    schema,
    data: SEED_ORDERS,
    tableCode: TABLE_CODE,
    getRowId: (r) => r.id,
    defaultPageSize: 8,
    features: {},
  });

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Compose named regions instead of the recipe. Swap{" "}
        <code className="text-foreground">DataTablePagination</code> for{" "}
        <code className="text-foreground">DataTableLoadMore</code> (cursor) or
        add <code className="text-foreground">DataTableViewTabs</code> above the
        body for preset tabs.
      </p>
      <div className="h-[min(400px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
        <DataTableRoot dataTable={dt} fillHeight className="min-h-0">
          {/* <DataTableViewTabs /> — preset filter tabs (surface B) */}
          <DataTableBody />
          <DataTablePagination />
          {/* <DataTableLoadMore /> — cursor "load more" footer */}
        </DataTableRoot>
      </div>
    </div>
  );
}

/** Compose named regions instead of the `<DataTable>` recipe. */
export function DataTableBespokeRegionsDemo() {
  return (
    <QueryClientProvider client={queryClient}>
      <DataTableBespokeRegionsDemoInner />
    </QueryClientProvider>
  );
}

Static data prop

Pass a data array for in-memory lists (mutually exclusive with adapter). The hook runs client-side sort, filter, and pagination over the array — ideal for modals, drawers, and docs demos.

<DataTable
  schema={schema}
  data={rows}
  getRowId={(r) => r.id}
  filtering="none"
  disablePagination
  features={{}}
/>
ORD-1001Acme CorpConfirmed$2,499.00
ORD-1002Globex IncShipped$849.50
ORD-1003InitechDraft$120.00
ORD-1004Umbrella LLCDelivered$3,200.00
ORD-1005Soylent CorpCancelled$45.99
ORD-1006Wayne EnterprisesConfirmed$18,750.00
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";

const TABLE_CODE = "docs-dt-minimal-static";

/** Smallest recipe: static rows, no filter chrome, no pagination footer. */
export function DataTableMinimalStaticDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 110,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  const rows = useMemo(() => SEED_ORDERS.slice(0, 6), []);

  return (
    <div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
      <DataTable<Order>
        fillHeight
        className="min-h-0"
        schema={schema}
        data={rows}
        tableCode={TABLE_CODE}
        getRowId={(r) => r.id}
        filtering="none"
        disablePagination
        features={{}}
      />
    </div>
  );
}

For adapter-shaped in-memory data (legacy call sites), createInMemoryListAdapter({ items }) still works.

Static rows via the data prop and no URL sync. For server paging, pass an adapter and add useTableSearchParams.

ORD-1001Acme CorpConfirmed$2,499.00
ORD-1002Globex IncShipped$849.50
ORD-1003InitechDraft$120.00
ORD-1004Umbrella LLCDelivered$3,200.00
ORD-1005Soylent CorpCancelled$45.99
ORD-1006Wayne EnterprisesConfirmed$18,750.00
ORD-1007Stark IndustriesShipped$5,600.00
ORD-1008OscorpDraft$299.99
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
} from "@/demos/data-table/demo-data";

const TABLE_CODE = "docs-dt-embedded";

/**
 * In-memory static slice: `data={rows}` + optional `disablePagination` to hide
 * the footer. No URL sync in this example.
 */
export function DataTableEmbeddedDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 110,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  const rows = useMemo(() => SEED_ORDERS.slice(0, 8), []);

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Static rows via the <code>data</code> prop and no URL sync. For
        server paging, pass an <code>adapter</code> and add{" "}
        <code>useTableSearchParams</code>.
      </p>
      <div className="h-[min(320px,50vh)] min-h-[220px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={rows}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          filtering="none"
          disablePagination
        />
      </div>
    </div>
  );
}

Features opt-in

New tables should pass features explicitly on useDataTable / <DataTable>. Absent keys stay off — no filter compilation, selection state, or editing surface.

features keyGates
filtersFilter pipeline (field compilation + filter state)
selectionRow selection + batch actions
editingInline / row / batch editing
columnsColumn manager + resizing
pagination{ mode?: "offset" | "cursor" | "infinite" }

<DataTable> derives features from recipe props (filtering, paginationMode, batchActions, …). Direct useDataTable callers pass features explicitly — absent key = off.

useDataTable({
  schema,
  data: rows,
  features: { filters: true, pagination: { mode: "offset" } },
});

Filter surfaces (filtering)

The recipe filtering prop selects one of five surfaces. Column-header filters (surface C) compile from schema filter config and work when features.filters is on, even with filtering="none".

filteringSurfaceUI
"none"No filter chrome
"search"AGlobal keyword search in the toolbar
"tabs"BSegmented preset tabs (<DataTableViewTabs>)
(column headers)CPer-column header dropdowns (schema filter; filtering="none" + features.filters)
"faceted"DFacet chips (facets prop + <DataTableFacetedFilter>)
"builder"EAdd-filter + chips + optional side panel

Search only (surface A)

Global keyword search in the toolbar — no filter builder or side panel.

ORD-1001Acme CorpConfirmed$2,499.00
ORD-1002Globex IncShipped$849.50
ORD-1003InitechDraft$120.00
ORD-1004Umbrella LLCDelivered$3,200.00
ORD-1005Soylent CorpCancelled$45.99
ORD-1006Wayne EnterprisesConfirmed$18,750.00
ORD-1007Stark IndustriesShipped$5,600.00
ORD-1008OscorpDraft$299.99
ORD-1009Cyberdyne SystemsConfirmed$14,200.00
ORD-1010Wonka IndustriesDelivered$89.00
ORD-1011Acme CorpShipped$1,340.00
ORD-1012Globex IncConfirmed$210.50
ORD-1013InitechDraft$7,800.00
ORD-1014Umbrella LLCShipped$450.00
ORD-1015Soylent CorpConfirmed$1,100.00
ORD-1016Wayne EnterprisesDelivered$9,300.00
ORD-1017Stark IndustriesDraft$65.00
ORD-1018OscorpCancelled$2,100.00
ORD-1019Cyberdyne SystemsConfirmed$32,000.00
ORD-1020Wonka IndustriesShipped$175.00
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";

const TABLE_CODE = "docs-dt-search-only";

/** Default search toolbar only (surface A) — no builder, panel, or facets. */
export function DataTableSearchOnlyDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 110,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Global keyword search in the toolbar — no filter builder or side panel.
      </p>
      <div className="h-[min(380px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={SEED_ORDERS}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          filtering="search"
          disablePagination
        />
      </div>
    </div>
  );
}

View tabs (surface B)

Preset tabs apply saved filter models. Pass initialFilterPresets or wire remote save/delete callbacks.

Preset tabs apply saved filter models — no search bar or builder panel.

ORD-1001Acme CorpConfirmedHigh
ORD-1002Globex IncShippedMedium
ORD-1003InitechDraftLow
ORD-1004Umbrella LLCDeliveredMedium
ORD-1005Soylent CorpCancelledLow
ORD-1006Wayne EnterprisesConfirmedUrgent
ORD-1007Stark IndustriesShippedHigh
ORD-1008OscorpDraftMedium
ORD-1009Cyberdyne SystemsConfirmedHigh
ORD-1010Wonka IndustriesDeliveredLow
ORD-1011Acme CorpShippedMedium
ORD-1012Globex IncConfirmedLow
ORD-1013InitechDraftUrgent
ORD-1014Umbrella LLCShippedHigh
ORD-1015Soylent CorpConfirmedMedium
ORD-1016Wayne EnterprisesDeliveredHigh
ORD-1017Stark IndustriesDraftLow
ORD-1018OscorpCancelledMedium
ORD-1019Cyberdyne SystemsConfirmedUrgent
ORD-1020Wonka IndustriesShippedLow
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
  PRIORITY_VARIANTS,
} from "./demo-data";

const TABLE_CODE = "docs-dt-view-tabs";

/** Segmented preset tabs (surface B) over the shared filter model. */
export function DataTableViewTabsDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 110,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
            filter: {
              valueType: "enum",
              paramKey: "status",
              logicVar: "status",
              options: Object.entries(STATUS_VARIANTS).map(([value, { label }]) => ({
                value,
                label,
              })),
            },
          },
          priority: {
            kind: "status",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 90,
            filter: {
              valueType: "enum",
              paramKey: "priority",
              logicVar: "priority",
              options: Object.entries(PRIORITY_VARIANTS).map(([value, { label }]) => ({
                value,
                label,
              })),
            },
          },
        },
        {},
      ),
    [],
  );

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Preset tabs apply saved filter models — no search bar or builder panel.
      </p>
      <div className="h-[min(380px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={SEED_ORDERS}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          filtering="tabs"
          disablePagination
          initialFilterPresets={[
            {
              id: "preset-all",
              name: "All orders",
              filterModel: {},
              createdAt: new Date("2026-01-01").toISOString(),
              updatedAt: new Date("2026-01-01").toISOString(),
            },
            {
              id: "preset-shipped",
              name: "Shipped",
              filterModel: {
                status: { op: "equals", value: "shipped" },
              },
              createdAt: new Date("2026-01-02").toISOString(),
              updatedAt: new Date("2026-01-02").toISOString(),
            },
            {
              id: "preset-urgent",
              name: "Urgent",
              filterModel: {
                priority: { op: "equals", value: "urgent" },
              },
              createdAt: new Date("2026-01-03").toISOString(),
              updatedAt: new Date("2026-01-03").toISOString(),
            },
          ]}
        />
      </div>
    </div>
  );
}

Column-header filters (surface C)

Per-column filter icons in headers — no search bar, builder, or facet chips.

ORD-1001Acme CorpConfirmedHigh$2,499.00
ORD-1002Globex IncShippedMedium$849.50
ORD-1003InitechDraftLow$120.00
ORD-1004Umbrella LLCDeliveredMedium$3,200.00
ORD-1005Soylent CorpCancelledLow$45.99
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00
ORD-1007Stark IndustriesShippedHigh$5,600.00
ORD-1008OscorpDraftMedium$299.99
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00
ORD-1010Wonka IndustriesDeliveredLow$89.00
ORD-1011Acme CorpShippedMedium$1,340.00
ORD-1012Globex IncConfirmedLow$210.50
ORD-1013InitechDraftUrgent$7,800.00
ORD-1014Umbrella LLCShippedHigh$450.00
ORD-1015Soylent CorpConfirmedMedium$1,100.00
ORD-1016Wayne EnterprisesDeliveredHigh$9,300.00
ORD-1017Stark IndustriesDraftLow$65.00
ORD-1018OscorpCancelledMedium$2,100.00
ORD-1019Cyberdyne SystemsConfirmedUrgent$32,000.00
ORD-1020Wonka IndustriesShippedLow$175.00
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
  PRIORITY_VARIANTS,
} from "./demo-data";

const TABLE_CODE = "docs-dt-column-header";

/**
 * Column-header filters (surface C): schema `filter` compiles to `filterField`
 * column meta via `generateColumnDefs`. Recipe uses `filtering="none"` so only
 * header dropdowns edit the shared filter model.
 */
export function DataTableColumnHeaderDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 110,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
            filter: {
              valueType: "string",
              paramKey: "customer",
              logicVar: "customer",
              placeholder: "Search customer…",
            },
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
            filter: {
              valueType: "enum",
              paramKey: "status",
              logicVar: "status",
              options: Object.entries(STATUS_VARIANTS).map(([value, { label }]) => ({
                value,
                label,
              })),
            },
          },
          priority: {
            kind: "status",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 90,
            filter: {
              valueType: "enum",
              paramKey: "priority",
              logicVar: "priority",
              options: Object.entries(PRIORITY_VARIANTS).map(([value, { label }]) => ({
                value,
                label,
              })),
            },
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Per-column filter icons in headers — no search bar, builder, or facet
        chips.
      </p>
      <div className="h-[min(380px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={SEED_ORDERS}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          filtering="none"
          features={{ filters: true }}
          disablePagination
        />
      </div>
    </div>
  );
}

Faceted filter (surface D)

Facet chips narrow governed fields without opening the builder panel.

ORD-1001Acme CorpConfirmed$2,499.00
ORD-1002Globex IncShipped$849.50
ORD-1003InitechDraft$120.00
ORD-1004Umbrella LLCDelivered$3,200.00
ORD-1005Soylent CorpCancelled$45.99
ORD-1006Wayne EnterprisesConfirmed$18,750.00
ORD-1007Stark IndustriesShipped$5,600.00
ORD-1008OscorpDraft$299.99
ORD-1009Cyberdyne SystemsConfirmed$14,200.00
ORD-1010Wonka IndustriesDelivered$89.00
ORD-1011Acme CorpShipped$1,340.00
ORD-1012Globex IncConfirmed$210.50
ORD-1013InitechDraft$7,800.00
ORD-1014Umbrella LLCShipped$450.00
ORD-1015Soylent CorpConfirmed$1,100.00
ORD-1016Wayne EnterprisesDelivered$9,300.00
ORD-1017Stark IndustriesDraft$65.00
ORD-1018OscorpCancelled$2,100.00
ORD-1019Cyberdyne SystemsConfirmed$32,000.00
ORD-1020Wonka IndustriesShipped$175.00
"use client";

import { useMemo } from "react";

import type { FacetCapability } from "@/components/f-ui/data-table/contract/capabilities";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";

const TABLE_CODE = "docs-dt-faceted-filter";

const ORDER_STATUS_FACETS: ReadonlyArray<FacetCapability> = [
  {
    id: "status:all",
    dimension: "status",
    default: true,
    neutral: true,
    governs: ["status"],
  },
  {
    id: "status:shipped",
    dimension: "status",
    neutral: false,
    governs: ["status"],
  },
  {
    id: "status:delivered",
    dimension: "status",
    neutral: false,
    governs: ["status"],
  },
];

/** Faceted multi-select toolbar (surface D). */
export function DataTableFacetedFilterDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 110,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
            filter: false, // R8: facets (D) govern status — no column-header (C).
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Facet chips narrow governed fields without opening the builder panel.
      </p>
      <div className="h-[min(380px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={SEED_ORDERS}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          filtering="faceted"
          facets={ORDER_STATUS_FACETS}
          disablePagination
        />
      </div>
    </div>
  );
}

Filter builder (surface E)

The default bazza-style builder: chips, add-filter, and optional side panel. Tune layout with filters={{ surface, chips }} (see Builder layout below).

Bazza-style chips and side panel — the default builder surface.

ORD-1001Acme CorpConfirmedHigh$2,499.00
ORD-1002Globex IncShippedMedium$849.50
ORD-1003InitechDraftLow$120.00
ORD-1004Umbrella LLCDeliveredMedium$3,200.00
ORD-1005Soylent CorpCancelledLow$45.99
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00
ORD-1007Stark IndustriesShippedHigh$5,600.00
ORD-1008OscorpDraftMedium$299.99
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00
ORD-1010Wonka IndustriesDeliveredLow$89.00
1–10 of 20
Rows per page
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
  PRIORITY_VARIANTS,
} from "./demo-data";

const TABLE_CODE = "docs-dt-builder";

/** Filter builder + chips (surface E) via `filtering="builder"`. */
export function DataTableBuilderDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 110,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
            filter: {
              valueType: "string",
              paramKey: "customer",
              logicVar: "customer",
              placeholder: "Search customer…",
            },
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
            filter: {
              valueType: "enum",
              paramKey: "status",
              logicVar: "status",
              options: Object.entries(STATUS_VARIANTS).map(([value, { label }]) => ({
                value,
                label,
              })),
            },
          },
          priority: {
            kind: "status",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 90,
            filter: {
              valueType: "enum",
              paramKey: "priority",
              logicVar: "priority",
              options: Object.entries(PRIORITY_VARIANTS).map(([value, { label }]) => ({
                value,
                label,
              })),
            },
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 100,
            filter: {
              valueType: "number",
              paramKey: "amount",
              logicVar: "amount",
            },
          },
        },
        {
          filterGroups: [
            { label: "Order", fields: ["customer", "status", "priority"] },
            { label: "Details", fields: ["amount"] },
          ],
        },
      ),
    [],
  );

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Bazza-style chips and side panel — the default builder surface.
      </p>
      <div className="h-[min(400px,55vh)] min-h-[280px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={SEED_ORDERS}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          filtering="builder"
          defaultPageSize={10}
        />
      </div>
    </div>
  );
}

Pagination modes (paginationMode)

paginationModeFooter regionData pipeline
"offset" (default)<DataTablePagination> numbered pagerOffset / page index
"cursor"<DataTableLoadMore> manual buttonCursor pages
"infinite"<DataTableLoadMoreSentinel> auto-fetchCursor pages

Use disablePagination to hide the footer and show all rows (defaultPageSize: "all"). Cursor and infinite modes require an adapter that returns nextCursor / hasNext.

Scroll the table body — the sentinel fetches the next cursor page automatically (120 rows, 8 per page).

Loading…
End of list
"use client";

import { useMemo } from "react";

import type { ListQueryAdapter } from "@/components/f-ui/data-table/adapters/list-query-contract";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, expandSeedOrders, STATUS_VARIANTS } from "./demo-data";

const TABLE_CODE = "docs-dt-infinite-scroll";
const PAGE_SIZE = 8;

function createCursorPagedOrdersAdapter(
  items: readonly Order[],
): ListQueryAdapter<Order> {
  const adapter: ListQueryAdapter<Order> = async (request) => {
    const offset = request.cursor ? Number.parseInt(request.cursor, 10) : 0;
    const pageSize = Math.max(1, request.pageSize);
    const slice = items.slice(offset, offset + pageSize);
    const nextOffset = offset + pageSize;
    const hasNext = nextOffset < items.length;

    return {
      items: slice,
      totalItems: items.length,
      nextCursor: hasNext ? String(nextOffset) : null,
      hasNext,
    };
  };
  adapter.versionKey = ["demo-infinite-scroll", String(items.length)];
  return adapter;
}

/** Cursor pipeline with auto load-more sentinel (surface infinite). */
export function DataTableInfiniteScrollDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 110,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  const rows = useMemo(() => expandSeedOrders(6), []);

  const adapter = useMemo(
    () => createCursorPagedOrdersAdapter(rows),
    [rows],
  );

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Scroll the table body — the sentinel fetches the next cursor page
        automatically ({rows.length} rows, {PAGE_SIZE} per page).
      </p>
      <div className="h-[min(400px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          adapter={adapter}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          defaultPageSize={PAGE_SIZE}
          paginationMode="infinite"
          filtering="none"
        />
      </div>
    </div>
  );
}

Infinite scroll (live API)

Same cursor / sentinel pipeline as above, but each page is fetched from the public demo orders list API (POST /api/v1/demo/demo-orders/list). The adapter wraps offset pagination as opaque cursors ("2" = page 2). Ensure the database is migrated and seeded (pnpm db:migrate, pnpm run db:seed:demo-orders).

Scroll the table body — each page loads 25 rows from the demo orders API. Requires a migrated, seeded database (pnpm db:migrate, pnpm run db:seed:demo-orders).

Loading…
End of list
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { createDemoOrdersCursorListAdapter } from "@/features/demo-orders/adapters/create-demo-orders-cursor-list-adapter";
import { type Order, STATUS_VARIANTS } from "./demo-data";

const TABLE_CODE = "docs-dt-infinite-api";
const PAGE_SIZE = 25;

/**
 * Infinite scroll against the live demo-orders list API (`POST …/demo/demo-orders/list`).
 * Each sentinel fetch requests the next offset page via a cursor wrapper.
 */
export function DataTableInfiniteScrollApiDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            pinned: "left",
            size: 120,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 160,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 110,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 110,
          },
          createdAt: {
            kind: "date",
            label: "Created",
            sortKey: "createdAt",
            size: 120,
          },
        },
        {},
      ),
    [],
  );

  const adapter = useMemo(() => createDemoOrdersCursorListAdapter(), []);

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Scroll the table body — each page loads{" "}
        <span className="text-foreground font-medium">{PAGE_SIZE}</span> rows from
        the demo orders API. Requires a migrated, seeded database (
        <code className="text-foreground">pnpm db:migrate</code>,{" "}
        <code className="text-foreground">pnpm run db:seed:demo-orders</code>).
      </p>
      <div className="h-[min(400px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          adapter={adapter}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          defaultPageSize={PAGE_SIZE}
          paginationMode="infinite"
          filtering="none"
        />
      </div>
    </div>
  );
}

Status Tag and formatters

Schema enum columns split by field intent:

IntentColumn kindComponentVariant map
Lifecycle / outcome / severity (status, priority)"status"StatusTagStatusVariantMap{ label, tone, icon? } per key; omit icon in tables; use icon: "auto" for tone presets
Category / dimension / role (category, channel, type)"label"LabelTagLabelVariantMap{ label, accent? } per key; optional accent: "auto" hashes to chart accents

Decision rule: if the value changing implies good/bad/urgent → kind: "status". Otherwise → kind: "label". Never use semantic status tones on categorical label fields. See Tag Selection for the full scenario matrix (Badge vs tags, filter chips, review checklist).

import type { LabelVariantMap, StatusVariantMap } from "@/components/f-ui/data-table/schema/types";

const STATUS_VARIANTS: StatusVariantMap = {
  active: { label: "Active", tone: "success" },
  pending: { label: "Pending", tone: "warning", icon: "auto" },
  processing: { label: "Processing", tone: "info", icon: "spin" },
};

const CATEGORY_VARIANTS: LabelVariantMap = {
  electronics: { label: "Electronics" },
  apparel: { label: "Apparel", accent: "accent-2" },
  other: { label: "Other", accent: "auto" },
};

// schema column
{ key: "status", kind: "status", variants: STATUS_VARIANTS }
{ key: "category", kind: "label", variants: CATEGORY_VARIANTS }

<StatusTag> and <LabelTag> are also available in custom cell renderers:

import { LabelTag } from "@/components/f-ui/label-tag";
import { resolveLabelAccent } from "@/components/f-ui/label-tag-accent";
import { StatusTag } from "@/components/f-ui/status-tag";

cell: ({ row }) => (
  <StatusTag tone="success">{row.original.status}</StatusTag>
);

cell: ({ row }) => (
  <LabelTag accent={resolveLabelAccent(row.original.category)}>
    {row.original.category}
  </LabelTag>
);

lib/formatters.ts provides pure display helpers aligned with the data-display spec:

HelperOutput
formatTableNumber(n)Thousands-separated number
formatAmount(n, { currency, symbol })$1,204.00
formatAbsoluteDateTime(d)YYYY-MM-DD HH:mm
formatDateRange(a, b)2018-12-08 ~ 2019-12-07
formatRelativeTime(d)just now / 5 minutes ago / tiered fallbacks
emptyOr(v)- for null/empty (aligns with empty-cell placeholder)

Choosing a configuration

Pick one data mode and add features as needed:

SituationStart here
Parent already has rows (modal, drawer)data={rows} or createInMemoryListAdapter — often no nuqs
Standard list: sort & paging in the URL, data from a fetcheradapter + useTableSearchParams
Users need ad-hoc filters, shareable linksfiltering="builder" (or another surface) + URL hook
Workflows with multi-select and bulk actionsfeatures.selection + batchActions
API accepts JSON Logic (or you translate to SQL)buildJsonLogicFilter + adapter
Infinite feed or load-morepaginationMode="infinite" or "cursor" + cursor adapter
Custom chrome layout<DataTableRoot> + named regions
Operators want more rows visibledefaultDensity="compact" (default remains normal)

Editing

Opt-in editing is configured on useDataTable({ editing: { … } }) or the same object on the uncontrolled <DataTable>. Add an edit field on schema columns that should participate.

Editing strategies

Every editable column with an edit config routes through DataTableEditableCell, which resolves a field editor from the column kind (edit: true) or an explicit override. Cell and batch strategies compose Inline Edit; row strategy reuses the same editors without per-cell Inline Edit chrome.

scope × commitRead modePer-cell chromeCommit boundary
cell + immediateHover pencil + click display → editSave / discard on active cellcommitCell on save; default commitMode: "manual"
row + immediateNormal cells until row edit startsNo pencil; no per-cell save/cancelRow actions save/cancel via commitRow — start editing with startRowEdit from a row action, not by clicking a cell
cell + batchHover pencil + click display → editCross discards cell draft (no per-cell save)DataTableEditingBar Save all / Cancel all

Cell editing (text)

Use scope: "cell" and commit: "immediate" for per-cell save/cancel. The demo uses the product default commitMode: "manual" — blur and Enter do not commit unless you override. Set commitMode to blur-or-enter or blur when you want focus-based commit instead.

Column authoring: edit: true on a column (editor inferred from kind), or edit: { validate, editor, … } for overrides.

ORD-1001
Acme Corp
Ship with padded envelope. Call before delivery.
ORD-1002
Globex Inc
ORD-1003
Initech
Awaiting payment confirmation
ORD-1004
Umbrella LLC
ORD-1005
Soylent Corp
Customer requested cancellation
"use client";

import { useMemo, useState } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { applyOrderCellEdit } from "@/demos/data-table/demo-editing-commit";
import { type Order, SEED_ORDERS } from "@/demos/data-table/demo-data";

const TABLE_CODE = "docs-dt-inline-edit";

export function DataTableInlineEditDemo() {
  const [rows, setRows] = useState(() =>
    SEED_ORDERS.slice(0, 5).map((row, index) =>
      index === 0
        ? { ...row, notes: "Ship with padded envelope.\nCall before delivery." }
        : row,
    ),
  );

  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 100,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
            edit: true,
          },
          notes: {
            kind: "text",
            label: "Notes",
            sortKey: "notes",
            size: 200,
            multiline: true,
            edit: true,
          },
        },
        {},
      ),
    [],
  );

  return (
    <div className="h-[min(360px,55vh)] min-h-[240px] overflow-hidden rounded-md border">
      <DataTable<Order>
        fillHeight
        className="min-h-0"
        schema={schema}
        data={rows}
        tableCode={TABLE_CODE}
        getRowId={(r) => r.id}
        filtering="none"
        disablePagination
        features={{ editing: {
          enabled: true,
          scope: "cell",
          commit: "immediate",
          commitMode: "manual",
          async onCommit(payload) {
            if (payload.strategy !== "cell") return;
            const patch = applyOrderCellEdit(payload.columnId, payload.value);
            setRows((prev) =>
              prev.map((r) =>
                r.id === payload.rowId ? { ...r, ...patch } : r,
              ),
            );
          },
        } }}
      />
    </div>
  );
}

Field-type editors (cell)

Set edit: true on any column whose kind supports editing. The table infers the control from kind (and status / label variants for enum options).

All editable cells use the Inline Edit read/edit row: value on the left, pencil on hover at the right; in edit mode, editor on the left and save/cancel (or cancel-only for batch) on the right. Pickers portal from the editor (filter-density controls in the edit row).

Column kindEditorRead display
textText inputTruncated string
numberNumber inputFormatted decimal
currencyCurrency inputFormatted money
dateDate pickerFormatted date
datetimeDate pickerFormatted date-time
booleanCheckboxYes / No labels
statusSelectStatus tag
labelSelectLabel tag

Order # stays read-only in the field-types demo.

ORD-1001
Acme Corp
Confirmed
High
Electronics
$2,499.00
5
2026-03-01
Yes
Expedite if possible
ORD-1002
Globex Inc
Shipped
Medium
Apparel
$849.50
12
2026-03-03
Yes
ORD-1003
Initech
Draft
Low
Food
$120.00
2
2026-03-05
Yes
Awaiting payment confirmation
ORD-1004
Umbrella LLC
Delivered
Medium
Electronics
$3,200.00
1
2026-02-20
Yes
ORD-1005
Soylent Corp
Cancelled
Low
Food
$45.99
3
2026-03-10
Yes
Customer requested cancellation
ORD-1006
Wayne Enterprises
Confirmed
Urgent
Electronics
$18,750.00
50
2026-03-12
Yes
Bulk order — warehouse B
"use client";

import { useMemo, useState } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { applyOrderCellEdit } from "@/demos/data-table/demo-editing-commit";
import {
  type Order,
  CATEGORY_VARIANTS,
  PRIORITY_VARIANTS,
  SEED_ORDERS,
  STATUS_VARIANTS,
} from "@/demos/data-table/demo-data";

const TABLE_CODE = "docs-dt-inline-edit-field-types";

/**
 * Showcases every inferred field editor (`edit: true` on column `kind`):
 * string, number, currency, date, boolean, and enum (badge).
 */
export function DataTableInlineEditFieldTypesDemo() {
  const [rows, setRows] = useState(() => SEED_ORDERS.slice(0, 6));

  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 108,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 132,
            edit: true,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 118,
            edit: true,
          },
          priority: {
            kind: "label",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 104,
            edit: true,
          },
          category: {
            kind: "label",
            label: "Category",
            sortKey: "category",
            variants: CATEGORY_VARIANTS,
            size: 118,
            edit: true,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 112,
            edit: true,
          },
          quantity: {
            kind: "number",
            label: "Qty",
            sortKey: "quantity",
            decimals: 0,
            size: 72,
            edit: true,
          },
          orderDate: {
            kind: "date",
            label: "Order date",
            sortKey: "orderDate",
            size: 120,
            edit: true,
          },
          shipped: {
            kind: "boolean",
            label: "Shipped",
            trueLabel: "Yes",
            falseLabel: "No",
            size: 88,
            edit: true,
          },
          notes: {
            kind: "text",
            label: "Notes",
            sortKey: "notes",
            size: 148,
            edit: true,
          },
        },
        {},
      ),
    [],
  );

  return (
    <div className="h-[min(440px,62vh)] min-h-[300px] overflow-hidden rounded-md border">
      <DataTable<Order>
        fillHeight
        className="min-h-0"
        schema={schema}
        data={rows}
        tableCode={TABLE_CODE}
        getRowId={(r) => r.id}
        filtering="none"
        disablePagination
        editing={{
          enabled: true,
          scope: "cell",
          commit: "immediate",
          commitMode: "manual",
          async onCommit(payload) {
            if (payload.strategy !== "cell") return;
            const patch = applyOrderCellEdit(payload.columnId, payload.value);
            setRows((prev) =>
              prev.map((r) =>
                r.id === payload.rowId ? { ...r, ...patch } : r,
              ),
            );
          },
        }}
      />
    </div>
  );
}

Datetime column

datetime columns share the date picker editor (valueType: "datetime"). Store ISO strings (or values your adapter accepts) and coerce in onCommit if needed.

ORD-1001Acme Corp
Mar 1, 2026 14:30
ORD-1002Globex Inc
Mar 3, 2026 14:30
ORD-1003Initech
Mar 5, 2026 14:30
ORD-1004Umbrella LLC
Feb 20, 2026 14:30
"use client";

import { useMemo, useState } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { applyOrderCellEdit } from "@/demos/data-table/demo-editing-commit";
import { type Order, SEED_ORDERS } from "@/demos/data-table/demo-data";

const TABLE_CODE = "docs-dt-inline-edit-datetime";

/** `datetime` columns use the same date picker editor as `date` (`valueType: "datetime"`). */
export function DataTableInlineEditDatetimeDemo() {
  const [rows, setRows] = useState(() =>
    SEED_ORDERS.slice(0, 4).map((r) => ({
      ...r,
      createdAt: `${r.createdAt}T14:30:00.000Z`,
    })),
  );

  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 108,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
          },
          createdAt: {
            kind: "datetime",
            label: "Created",
            sortKey: "createdAt",
            format: "MMM d, yyyy HH:mm",
            size: 168,
            edit: true,
          },
        },
        {},
      ),
    [],
  );

  return (
    <div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
      <DataTable<Order>
        fillHeight
        className="min-h-0"
        schema={schema}
        data={rows}
        tableCode={TABLE_CODE}
        getRowId={(r) => r.id}
        filtering="none"
        disablePagination
        editing={{
          enabled: true,
          scope: "cell",
          commit: "immediate",
          commitMode: "manual",
          async onCommit(payload) {
            if (payload.strategy !== "cell") return;
            const patch = applyOrderCellEdit(payload.columnId, payload.value);
            setRows((prev) =>
              prev.map((r) =>
                r.id === payload.rowId ? { ...r, ...patch } : r,
              ),
            );
          },
        }}
      />
    </div>
  );
}

Row editing

While a row is active, editable columns render editors only (no read/edit toggle or pencil). Use scope: "row" and rowActions (for example Edit row) that calls editing.startRowEdit(rowId, row, snapshotValues). The actions column swaps to save/cancel while the row is active. onCommit receives { strategy: "row", row, rowId, previousValues, values }.

Use the pencil action to enter row edit mode; save or cancel from the actions column.

Actions
ORD-1001Acme CorpConfirmed$2,499.005NoExpedite if possible
ORD-1002Globex IncShipped$849.5012Yes
ORD-1003InitechDraft$120.002NoAwaiting payment confirmation
ORD-1004Umbrella LLCDelivered$3,200.001Yes
ORD-1005Soylent CorpCancelled$45.993NoCustomer requested cancellation
"use client";

import { useMemo, useRef, useState } from "react";
import { PencilIcon } from "lucide-react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import type { UseDataTableEditingReturn } from "@/components/f-ui/data-table/hooks/use-data-table-editing";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import type { RowAction } from "@/components/f-ui/data-table/schema/types";
import { useDataTable } from "@/components/f-ui/data-table/use-data-table";
import { applyOrderRowEdit } from "@/demos/data-table/demo-editing-commit";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
} from "@/demos/data-table/demo-data";

const TABLE_CODE = "docs-dt-edit-row";

function rowEditSnapshot(row: Order): Record<string, unknown> {
  return {
    customer: row.customer,
    status: row.status,
    amount: row.amount,
    quantity: row.quantity,
    shipped: row.shipped,
    notes: row.notes,
  };
}

export function DataTableRowEditingDemo() {
  const [rows, setRows] = useState(() => SEED_ORDERS.slice(0, 5));
  const editingRef = useRef<UseDataTableEditingReturn<Order> | null>(null);

  const rowActions = useMemo<RowAction<Order>[]>(
    () => [
      {
        id: "edit-row",
        label: "Edit row",
        inline: true,
        icon: <PencilIcon className="size-4" aria-hidden />,
        onClick: (row) => {
          editingRef.current?.startRowEdit(row.id, row, rowEditSnapshot(row));
        },
      },
    ],
    [],
  );

  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 100,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 132,
            edit: true,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 112,
            edit: true,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 108,
            edit: true,
          },
          quantity: {
            kind: "number",
            label: "Qty",
            sortKey: "quantity",
            decimals: 0,
            size: 72,
            edit: true,
          },
          shipped: {
            kind: "boolean",
            label: "Shipped",
            trueLabel: "Yes",
            falseLabel: "No",
            size: 88,
            edit: true,
          },
          notes: {
            kind: "text",
            label: "Notes",
            sortKey: "notes",
            size: 140,
            edit: true,
          },
        },
        {},
      ),
    [],
  );

  const dt = useDataTable<Order>({
    schema,
    data: rows,
    tableCode: TABLE_CODE,
    getRowId: (r) => r.id,
    defaultPageSize: "all",
    rowActions,
    rowActionsDisplay: "inline",
    editing: {
      enabled: true,
      scope: "row",
      commit: "immediate",
      async onCommit(payload) {
        if (payload.strategy !== "row") return;
        const patch = applyOrderRowEdit(payload.values);
        setRows((prev) =>
          prev.map((r) =>
            r.id === payload.rowId ? { ...r, ...patch } : r,
          ),
        );
      },
    },
  });

  editingRef.current = dt.editing;

  return (
    <div className="h-[min(400px,58vh)] min-h-[260px] overflow-hidden rounded-md border">
      <DataTable<Order>
        dataTable={dt}
        fillHeight
        className="min-h-0"
        filtering="none"
        disablePagination
      />
    </div>
  );
}

Table editing

Use scope: "cell" and commit: "batch" to accumulate cell drafts and flush them in one onCommit payload: { strategy: "batch", changes }.

Staging flow (batch buffer):

  1. Hover a cell, click the value or pencil, edit in place.
  2. Edits stage as you type; blur does not exit the cell — only cross (discard) or Save all ends the batch session for that cell.
  3. Staged values render in medium weight until you flush or cancel.
  4. DataTableEditingBar is always visible in batch mode (Save all disabled until something is staged).
ORD-1001
Acme Corp
Confirmed
$2,499.00
Expedite if possible
ORD-1002
Globex Inc
Shipped
$849.50
ORD-1003
Initech
Draft
$120.00
Awaiting payment confirmation
ORD-1004
Umbrella LLC
Delivered
$3,200.00
ORD-1005
Soylent Corp
Cancelled
$45.99
Customer requested cancellation
"use client";

import { useMemo, useState } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { applyOrderCellEdit } from "@/demos/data-table/demo-editing-commit";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
} from "@/demos/data-table/demo-data";

const TABLE_CODE = "docs-dt-edit-table";

/** Batch staging demo: inline text + popover pickers, then Save all. */
export function DataTableTableEditingDemo() {
  const [rows, setRows] = useState(() => SEED_ORDERS.slice(0, 5));

  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 100,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
            edit: true,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 112,
            edit: true,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 112,
            edit: true,
          },
          notes: {
            kind: "text",
            label: "Notes",
            sortKey: "notes",
            size: 160,
            edit: true,
          },
        },
        {},
      ),
    [],
  );

  return (
    <div className="h-[min(360px,55vh)] min-h-[240px] overflow-hidden rounded-md border">
      <DataTable<Order>
        fillHeight
        className="min-h-0"
        schema={schema}
        data={rows}
        tableCode={TABLE_CODE}
        getRowId={(r) => r.id}
        filtering="none"
        disablePagination
        editing={{
          enabled: true,
          scope: "cell",
          commit: "batch",
          async onCommit(payload) {
            if (payload.strategy !== "batch") return;
            setRows((prev) =>
              payload.changes.reduce((acc, change) => {
                const patch = applyOrderCellEdit(
                  change.columnId,
                  change.value,
                );
                return acc.map((r) =>
                  r.id === change.rowId ? { ...r, ...patch } : r,
                );
              }, prev),
            );
          },
        }}
      />
    </div>
  );
}

Editing options (API)

OptionDescription
editing.enabledWhen true, exposes the editing surface on the table handle.
editing.scope"cell" (per-cell) or "row" (row-wide editors after startRowEdit).
editing.commit"immediate" (commit per cell/row) or "batch" (stage cell drafts; flush via commitAll / DataTableEditingBar).
editing.commitModePassed to Inline Edit for commit === "immediate" only. Batch always uses manual on cells (blur/Enter do not end edit); drafts stage on change.
editing.onCommitCalled with EditMutationInput (strategy: "cell", "row", or "batch") after validation succeeds.
editing.mutationAlternative to onCommit: async adapter invoked with the same payload shape.
editing.validateCellOptional async validator for cell-shaped payloads (including each entry in batch commits).
editing.validateRowOptional async validator for row drafts (cross-field rules; field validate receives siblings()).
Column edittrue infers editor from column kind; or edit: { validate, editor, presentation, … }.
editing.stateControlled: current { cellDrafts, activeRowId? }; use together with onStateChange.
editing.onStateChangeControlled: receives the next ControlledDataTableEditingState when hook actions mutate drafts.

Client mode (API window, then in-browser list)

One request loads the first 300 rows from the public demo list API (POST /api/v1/demo/demo-orders/list, Gostart-shaped envelope). After that, filter, sort, and pagination are handled entirely in the browser via the same in-memory list pipeline as createInMemoryListAdapter (see createClientWindowListQueryAdapter). The table may show a brief loading state on first paint until the window is fetched; ensure the database is migrated and seeded (pnpm db:migrate, pnpm run db:seed:demo-orders).

Loading…
No rows
Rows per page
"use client";

import { useMemo } from "react";

import { createClientWindowListQueryAdapter } from "@/components/f-ui/data-table/adapters/create-client-window-list-adapter";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { createDemoOrdersListAdapter } from "@/features/demo-orders/adapters/create-demo-orders-list-adapter";
import {
  type Order,
  STATUS_VARIANTS,
  PRIORITY_VARIANTS,
  CATEGORY_VARIANTS,
} from "@/demos/data-table/demo-data";

const TABLE_CODE = "docs-dt-client-1k";

/** First page size for the one-time API load; filter/sort/page run in-browser after that. */
const CLIENT_WINDOW = 300;

/** Static options for the channel column’s async-select filter (matches demo order data). */
const CLIENT_DEMO_CHANNEL_FILTER_OPTIONS: ReadonlyArray<{
  value: string;
  label: string;
}> = [
  { value: "web", label: "Web storefront" },
  { value: "phone", label: "Phone" },
  { value: "edi", label: "EDI" },
  { value: "pos", label: "POS" },
];

function clientDemoChannelFilterOptionsHook() {
  return { data: CLIENT_DEMO_CHANNEL_FILTER_OPTIONS, isLoading: false };
}

/**
 * Fetches the first 300 orders from the real demo list API, then filters,
 * sorts, and paginates in the browser (in-memory pipeline). Schema includes
 * every field on `Order` (same surface as the showcases / filters examples).
 */
export function DataTableClientModeDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            pinned: "left",
            size: 120,
            filter: {
              valueType: "string",
              paramKey: "orderNumber",
              logicVar: "orderNumber",
            },
          },
          id: {
            kind: "text",
            label: "ID",
            sortKey: "id",
            size: 100,
            filter: {
              valueType: "string",
              paramKey: "id",
              logicVar: "id",
            },
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 180,
            filter: {
              valueType: "string",
              paramKey: "customer",
              logicVar: "customer",
              placeholder: "Search customer…",
            },
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 120,
            filter: {
              valueType: "enum",
              paramKey: "status",
              logicVar: "status",
              options: Object.entries(STATUS_VARIANTS).map(
                ([value, { label }]) => ({ value, label }),
              ),
            },
          },
          priority: {
            kind: "status",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 100,
            filter: {
              valueType: "enum",
              paramKey: "priority",
              logicVar: "priority",
              options: Object.entries(PRIORITY_VARIANTS).map(
                ([value, { label }]) => ({ value, label }),
              ),
            },
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 120,
            filter: {
              valueType: "number",
              paramKey: "amount",
              logicVar: "amount",
              unit: "USD",
            },
          },
          quantity: {
            kind: "number",
            label: "Qty",
            sortKey: "quantity",
            decimals: 0,
            size: 72,
            filter: {
              valueType: "number",
              paramKey: "quantity",
              logicVar: "quantity",
            },
          },
          orderDate: {
            kind: "date",
            label: "Order date",
            sortKey: "orderDate",
            size: 120,
            filter: {
              valueType: "date",
              paramKey: "orderDate",
              logicVar: "orderDate",
            },
          },
          createdAt: {
            kind: "date",
            label: "Created at",
            sortKey: "createdAt",
            size: 120,
            filter: {
              valueType: "date",
              paramKey: "createdAt",
              logicVar: "createdAt",
            },
          },
          channel: {
            kind: "text",
            label: "Channel",
            sortKey: "channel",
            size: 120,
            filter: {
              valueType: "enum",
              paramKey: "channel",
              logicVar: "channel",
              options: CLIENT_DEMO_CHANNEL_FILTER_OPTIONS,
              optionsHook: clientDemoChannelFilterOptionsHook,
            },
          },
          shipped: {
            kind: "boolean",
            label: "Shipped?",
            sortKey: "shipped",
            trueLabel: "Yes",
            falseLabel: "No",
            size: 88,
            filter: {
              valueType: "boolean",
              paramKey: "shipped",
              logicVar: "shipped",
            },
          },
          shippedDate: {
            kind: "date",
            label: "Shipped date",
            sortKey: "shippedDate",
            size: 120,
            filter: {
              valueType: "date",
              paramKey: "shippedDate",
              logicVar: "shippedDate",
              nullable: true,
            },
          },
          category: {
            kind: "label",
            label: "Category",
            sortKey: "category",
            variants: CATEGORY_VARIANTS,
            size: 120,
            filter: {
              valueType: "enum",
              paramKey: "category",
              logicVar: "category",
              options: Object.entries(CATEGORY_VARIANTS).map(
                ([value, { label }]) => ({ value, label }),
              ),
            },
          },
          notes: {
            kind: "text",
            label: "Notes",
            sortKey: "notes",
            size: 200,
            filter: {
              valueType: "string",
              paramKey: "notes",
              logicVar: "notes",
              nullable: true,
            },
          },
        },
        {
          filterGroups: [
            {
              label: "Order",
              fields: [
                "orderNumber",
                "id",
                "customer",
                "status",
                "priority",
                "channel",
              ],
            },
            {
              label: "Details",
              fields: [
                "amount",
                "quantity",
                "orderDate",
                "createdAt",
                "shipped",
                "shippedDate",
                "category",
                "notes",
              ],
            },
          ],
        },
      ),
    [],
  );

  const listApi = useMemo(() => createDemoOrdersListAdapter(), []);
  const adapter = useMemo(
    () =>
      createClientWindowListQueryAdapter<Order>({
        loadWindow: (signal) =>
          Promise.resolve(
            listApi({
              page: 1,
              pageSize: CLIENT_WINDOW,
              filter: null,
              sort: undefined,
              signal,
            }),
          ).then((r) => r.items),
      }),
    [listApi],
  );

  return (
    <DataTable<Order>
      fillHeight
      className="h-[min(520px,75vh)] min-h-[280px]"
      schema={schema}
      adapter={adapter}
      getRowId={(r) => r.id}
      tableCode={TABLE_CODE}
      defaultPageSize={20}
      pageSizeOptions={[10, 20, 50, 100, "all"]}
      showDensityControl
      filtering="builder"
      features={{ filters: true, columns: { manager: true } }}
    />
  );
}

List or report page (URL + adapter, no filter UI)

Use for browse-heavy screens: sorting and paging should bookmark and survive refresh. Wire createInMemoryListAdapter or your API adapter with useTableSearchParams (nuqs). Enable showDensityControl when users may change row height. Below: ten columns, pinned Order #, filter UI off (filters omitted).

ORD-1001Acme CorpConfirmedHigh$2,499.0052026-03-01NoElectronics
ORD-1002Globex IncShippedMedium$849.50122026-03-032026-03-07YesApparel
ORD-1003InitechDraftLow$120.0022026-03-05NoFood
ORD-1004Umbrella LLCDeliveredMedium$3,200.0012026-02-202026-02-25YesElectronics
ORD-1005Soylent CorpCancelledLow$45.9932026-03-10NoFood
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00502026-03-12NoElectronics
ORD-1007Stark IndustriesShippedHigh$5,600.0082026-03-082026-03-14YesElectronics
ORD-1008OscorpDraftMedium$299.9942026-03-15NoHome
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00202026-03-11NoElectronics
ORD-1010Wonka IndustriesDeliveredLow$89.0062026-02-282026-03-04YesFood
1–10 of 20
Rows per page
"use client";

import { useMemo } from "react";
import { parseAsInteger } from "nuqs";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
  PRIORITY_VARIANTS,
  CATEGORY_VARIANTS,
} from "./demo-data";

export function DataTableReadonlyDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            pinned: "left",
            size: 120,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 160,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 110,
          },
          priority: {
            kind: "status",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 120,
          },
          quantity: {
            kind: "number",
            label: "Qty",
            sortKey: "quantity",
            decimals: 0,
            size: 80,
          },
          orderDate: {
            kind: "date",
            label: "Order Date",
            sortKey: "orderDate",
            size: 120,
          },
          shippedDate: {
            kind: "date",
            label: "Shipped",
            sortKey: "shippedDate",
            size: 120,
          },
          shipped: {
            kind: "boolean",
            label: "Shipped?",
            trueLabel: "Yes",
            falseLabel: "No",
            size: 90,
          },
          category: {
            kind: "label",
            label: "Category",
            sortKey: "category",
            variants: CATEGORY_VARIANTS,
            size: 110,
          },
        },
        {},
      ),
    [],
  );

  const { params, setParams } = useTableSearchParams(schema, {
    urlParamPrefix: "dt_ro_",
    extraParsers: {
      pageSize: parseAsInteger.withDefault(10),
    },
  });

  return (
    <DataTable<Order>
      fillHeight
      className="h-[min(520px,75vh)] min-h-[280px]"
      schema={schema}
      data={SEED_ORDERS}
      tableCode="docs-dt-readonly"
      params={params}
      onParamsChange={setParams}
      filtering="none"
      showDensityControl
    />
  );
}

Filters and shareable URLs

Use when analysts or admins need multi-criteria filters and shareable filter state. Keep useTableSearchParams and set filtering="builder" (surface E). Filter map syncs through the same params object. Column filters use valueType and optional presentation on filter config (not legacy dataType).

Filter state syncs to the URL via nuqs. Active filters: 0

ORD-1001Acme CorpConfirmedHigh$2,499.0052026-03-01NoElectronics
ORD-1002Globex IncShippedMedium$849.50122026-03-03YesApparel
ORD-1003InitechDraftLow$120.0022026-03-05NoFood
ORD-1004Umbrella LLCDeliveredMedium$3,200.0012026-02-20YesElectronics
ORD-1005Soylent CorpCancelledLow$45.9932026-03-10NoFood
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00502026-03-12NoElectronics
ORD-1007Stark IndustriesShippedHigh$5,600.0082026-03-08YesElectronics
ORD-1008OscorpDraftMedium$299.9942026-03-15NoHome
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00202026-03-11NoElectronics
ORD-1010Wonka IndustriesDeliveredLow$89.0062026-02-28YesFood
ORD-1011Acme CorpShippedMedium$1,340.00102026-03-06YesApparel
ORD-1012Globex IncConfirmedLow$210.5032026-03-14NoHome
ORD-1013InitechDraftUrgent$7,800.00152026-03-16NoElectronics
ORD-1014Umbrella LLCShippedHigh$450.0022026-03-09YesHome
ORD-1015Soylent CorpConfirmedMedium$1,100.0072026-03-13NoApparel
ORD-1016Wayne EnterprisesDeliveredHigh$9,300.00252026-02-15YesElectronics
ORD-1017Stark IndustriesDraftLow$65.0012026-03-17NoFood
ORD-1018OscorpCancelledMedium$2,100.0092026-03-02NoApparel
ORD-1019Cyberdyne SystemsConfirmedUrgent$32,000.001002026-03-18NoElectronics
ORD-1020Wonka IndustriesShippedLow$175.0042026-03-04YesFood
20 rows
Rows per page
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
  PRIORITY_VARIANTS,
  CATEGORY_VARIANTS,
} from "./demo-data";

export function DataTableFiltersDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            pinned: "left",
            size: 120,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 160,
            filter: {
              valueType: "string",
              paramKey: "customer",
              logicVar: "customer",
              placeholder: "Search customer…",
            },
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 110,
            filter: {
              valueType: "enum",
              paramKey: "status",
              logicVar: "status",
              options: Object.entries(STATUS_VARIANTS).map(
                ([value, { label }]) => ({
                  value,
                  label,
                }),
              ),
            },
          },
          priority: {
            kind: "status",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 100,
            filter: {
              valueType: "enum",
              paramKey: "priority",
              logicVar: "priority",
              options: Object.entries(PRIORITY_VARIANTS).map(
                ([value, { label }]) => ({
                  value,
                  label,
                }),
              ),
            },
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 120,
            filter: {
              valueType: "number",
              paramKey: "amount",
              logicVar: "amount",
              defaultOperator: "between",
            },
          },
          quantity: {
            kind: "number",
            label: "Qty",
            sortKey: "quantity",
            decimals: 0,
            size: 80,
          },
          orderDate: {
            kind: "date",
            label: "Order Date",
            sortKey: "orderDate",
            size: 130,
            filter: {
              valueType: "date",
              paramKey: "orderDate",
              logicVar: "orderDate",
            },
          },
          shipped: {
            kind: "boolean",
            label: "Shipped?",
            trueLabel: "Yes",
            falseLabel: "No",
            size: 90,
          },
          category: {
            kind: "label",
            label: "Category",
            sortKey: "category",
            variants: CATEGORY_VARIANTS,
            size: 110,
            filter: {
              valueType: "enum",
              paramKey: "category",
              logicVar: "category",
              options: Object.entries(CATEGORY_VARIANTS).map(
                ([value, { label }]) => ({
                  value,
                  label,
                }),
              ),
            },
          },
        },
        {
          filterGroups: [
            { label: "Order", fields: ["customer", "status", "priority"] },
            { label: "Details", fields: ["amount", "orderDate", "category"] },
          ],
        },
      ),
    [],
  );

  const { params, setParams, activeFilterCount } = useTableSearchParams(
    schema,
    { urlParamPrefix: "dt_fl_" },
  );

  return (
    <div className="space-y-3">
      <p className="text-muted-foreground text-xs">
        Filter state syncs to the URL via nuqs. Active filters:{" "}
        <span className="text-foreground font-medium tabular-nums">
          {activeFilterCount}
        </span>
      </p>
      <DataTable<Order>
        fillHeight
        className="h-[min(520px,75vh)] min-h-[280px]"
        schema={schema}
        data={SEED_ORDERS}
        getRowId={(r) => r.id}
        tableCode="docs-dt-filters"
        params={params}
        onParamsChange={setParams}
        filtering="builder"
        showDensityControl
      />
    </div>
  );
}

Selection and batch actions

Use for queues (approve, ship, archive): batchActions (recipe derives features.selection), rowActions, optional rowActionsDisplay. Confirm destructive batch work in your product before enabling.

Select rows for batch actions. Use the kebab menu in the trailing column for per-row actions.

Actions
ORD-1001Acme CorpConfirmedHigh$2,499.00
ORD-1002Globex IncShippedMedium$849.50
ORD-1003InitechDraftLow$120.00
ORD-1004Umbrella LLCDeliveredMedium$3,200.00
ORD-1005Soylent CorpCancelledLow$45.99
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00
ORD-1007Stark IndustriesShippedHigh$5,600.00
ORD-1008OscorpDraftMedium$299.99
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00
ORD-1010Wonka IndustriesDeliveredLow$89.00
ORD-1011Acme CorpShippedMedium$1,340.00
ORD-1012Globex IncConfirmedLow$210.50
ORD-1013InitechDraftUrgent$7,800.00
ORD-1014Umbrella LLCShippedHigh$450.00
ORD-1015Soylent CorpConfirmedMedium$1,100.00
ORD-1016Wayne EnterprisesDeliveredHigh$9,300.00
ORD-1017Stark IndustriesDraftLow$65.00
ORD-1018OscorpCancelledMedium$2,100.00
ORD-1019Cyberdyne SystemsConfirmedUrgent$32,000.00
ORD-1020Wonka IndustriesShippedLow$175.00
20 rows
Rows per page
"use client";

import { useMemo } from "react";
import {
  EyeIcon,
  PencilIcon,
  Trash2Icon,
  TruckIcon,
  XCircleIcon,
} from "lucide-react";
import { toast } from "sonner";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import type {
  BatchAction,
  RowAction,
} from "@/components/f-ui/data-table/schema/types";
import {
  type Order,
  PRIORITY_VARIANTS,
  SEED_ORDERS,
  STATUS_VARIANTS,
} from "./demo-data";

export function DataTableActionsDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            pinned: "left",
            size: 120,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 160,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 110,
          },
          priority: {
            kind: "status",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 120,
          },
        },
        {},
      ),
    [],
  );

  const { params, setParams } = useTableSearchParams(schema, {
    urlParamPrefix: "dt_ac_",
  });

  const rowActions = useMemo<RowAction<Order>[]>(
    () => [
      {
        id: "view",
        label: "View details",
        icon: <EyeIcon className="size-4" />,
        onClick: (row) => toast.info(`View: ${row.orderNumber}`),
      },
      {
        id: "edit",
        label: "Edit",
        icon: <PencilIcon className="size-4" />,
        onClick: (row) => toast.success(`Edit: ${row.orderNumber}`),
      },
      {
        id: "cancel",
        label: "Cancel order",
        icon: <XCircleIcon className="size-4" />,
        variant: "destructive",
        onClick: (row) => toast.warning(`Cancel: ${row.orderNumber}`),
        disabled: (row) =>
          row.status === "cancelled" || row.status === "delivered",
      },
    ],
    [],
  );

  const batchActions = useMemo<BatchAction<Order>[]>(
    () => [
      {
        id: "ship",
        label: "Mark shipped",
        icon: <TruckIcon className="size-4" />,
        onClick: ({ rows, count }) => {
          toast.success(`Shipping ${rows.length || count} order(s)`);
        },
        disabled: (rows) => {
          const allShipped = rows.every((r) => r.shipped);
          return allShipped ? "All selected orders are already shipped" : false;
        },
      },
      {
        id: "delete",
        label: "Delete",
        icon: <Trash2Icon className="size-4" />,
        variant: "destructive",
        confirmBeforeRun: true,
        onClick: ({ rows, count }) => {
          toast.success(`Deleted ${rows.length || count} order(s)`);
        },
      },
    ],
    [],
  );

  return (
    <div className="space-y-3">
      <p className="text-muted-foreground text-xs">
        Select rows for batch actions. Use the kebab menu in the trailing
        column for per-row actions.
      </p>
      <DataTable<Order>
        fillHeight
        className="h-[min(520px,75vh)] min-h-[280px]"
        schema={schema}
        data={SEED_ORDERS}
        getRowId={(r) => r.id}
        tableCode="docs-dt-actions"
        params={params}
        onParamsChange={setParams}
        rowActions={rowActions}
        batchActions={batchActions}
        filtering="none"
        features={{ selection: true }}
        showDensityControl
      />
    </div>
  );
}

Server-shaped list + JSON Logic

Use when the backend expects a JSON Logic (or equivalent) payload built from the current filter model. The demo previews the rule your adapter would send.

adapter request.filter →
null (no active filters)
Loading…
No rows
Rows per page
"use client";

import { useCallback, useMemo, useState } from "react";
import { parseAsInteger } from "nuqs";
import { useQueryClient } from "@tanstack/react-query";

import { createInMemoryListAdapter } from "@/components/f-ui/data-table/adapters/in-memory-list-adapter";
import type {
  JsonLogicRule,
  ListQueryAdapter,
} from "@/components/f-ui/data-table/adapters/list-query-contract";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
  CATEGORY_VARIANTS,
} from "./demo-data";

export function DataTableServerJsonLogicDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            pinned: "left",
            size: 120,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 160,
            filter: {
              valueType: "string",
              paramKey: "customer",
              logicVar: "customer",
            },
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 110,
            filter: {
              valueType: "enum",
              paramKey: "status",
              logicVar: "status",
              options: Object.entries(STATUS_VARIANTS).map(
                ([value, { label }]) => ({
                  value,
                  label,
                }),
              ),
            },
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 120,
            filter: {
              valueType: "number",
              paramKey: "amount",
              logicVar: "amount",
              defaultOperator: "between",
            },
          },
          category: {
            kind: "label",
            label: "Category",
            sortKey: "category",
            variants: CATEGORY_VARIANTS,
            size: 110,
            filter: {
              valueType: "enum",
              paramKey: "category",
              logicVar: "category",
              options: Object.entries(CATEGORY_VARIANTS).map(
                ([value, { label }]) => ({
                  value,
                  label,
                }),
              ),
            },
          },
        },
        {
          filterGroups: [
            {
              label: "Filters",
              fields: ["customer", "status", "amount", "category"],
            },
          ],
        },
      ),
    [],
  );

  const { params, setParams } = useTableSearchParams(schema, {
    urlParamPrefix: "dt_jl_",
    extraParsers: {
      pageSize: parseAsInteger.withDefault(10),
    },
  });

  const baseAdapter = useMemo(
    () => createInMemoryListAdapter<Order>({ items: SEED_ORDERS }),
    [],
  );

  const [latestFilter, setLatestFilter] = useState<JsonLogicRule | null>(null);

  // Pedagogical shim: capture each adapter call's `request.filter` into local
  // state so the debug panel below can render the live JSON-Logic payload.
  const adapter = useCallback<ListQueryAdapter<Order>>(
    async (request) => {
      setLatestFilter(request.filter ?? null);
      return baseAdapter(request);
    },
    [baseAdapter],
  );

  const queryClient = useQueryClient();

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap gap-2">
        <button
          type="button"
          className="border-input bg-background hover:bg-muted/50 rounded-md border px-2 py-1 text-xs"
          onClick={() => {
            queryClient.invalidateQueries({
              queryKey: ["data-table", "docs-dt-json-logic"],
            });
          }}
        >
          Refetch
        </button>
      </div>
      <div className="border-input bg-muted/30 rounded-md border p-3 font-mono text-[11px] leading-relaxed">
        <div className="text-muted-foreground mb-1">
          adapter request.filter →
        </div>
        <pre className="text-foreground max-h-32 overflow-auto whitespace-pre-wrap break-all">
          {latestFilter == null
            ? "null (no active filters)"
            : JSON.stringify(latestFilter, null, 2)}
        </pre>
      </div>
      <DataTable<Order>
        fillHeight
        className="h-[min(520px,75vh)] min-h-[280px]"
        schema={schema}
        adapter={adapter}
        getRowId={(r) => r.id}
        tableCode="docs-dt-json-logic"
        params={params}
        onParamsChange={setParams}
        filtering="builder"
        showDensityControl
      />
    </div>
  );
}

Compact density (optional)

Default row padding is normal. Switch to compact for dense monitoring or ops layouts; keep showDensityControl if users should override.

Default density is normal. Set defaultDensity="compact" (and optional showDensityControl) when operators need more rows on screen.

ORD-1001Acme CorpConfirmedHigh
ORD-1002Globex IncShippedMedium
ORD-1003InitechDraftLow
ORD-1004Umbrella LLCDeliveredMedium
ORD-1005Soylent CorpCancelledLow
ORD-1006Wayne EnterprisesConfirmedUrgent
ORD-1007Stark IndustriesShippedHigh
ORD-1008OscorpDraftMedium
1–8 of 20
Rows per page
"use client";

import { useMemo } from "react";
import { parseAsInteger } from "nuqs";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
  PRIORITY_VARIANTS,
} from "@/demos/data-table/demo-data";

const TABLE_CODE = "docs-dt-compact-density";

/** Dense rows for dashboards or power users; default product density remains `normal`. */
export function DataTableCompactDensityDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 108,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 150,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
          },
          priority: {
            kind: "status",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 90,
          },
        },
        {},
      ),
    [],
  );

  const { params, setParams } = useTableSearchParams(schema, {
    urlParamPrefix: "dt_cd_",
    extraParsers: {
      pageSize: parseAsInteger.withDefault(8),
    },
  });

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Default density is <strong>normal</strong>. Set{" "}
        <code>defaultDensity=&quot;compact&quot;</code> (and optional{" "}
        <code>showDensityControl</code>) when operators need more rows on
        screen.
      </p>
      <div className="h-[min(360px,55vh)] min-h-[240px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={SEED_ORDERS}
          tableCode={TABLE_CODE}
          params={params}
          onParamsChange={setParams}
          filtering="none"
          showDensityControl
          defaultDensity="compact"
        />
      </div>
    </div>
  );
}

Loading State

On first load (data.isLoading), the real column header stays and only the body switches — by default to density-aware skeleton rows aligned to column widths (interaction is locked by the root scrim). Set loadingVariant="spinner" for a centered spinner instead (Ant-style). Background refetches keep the current rows and show the interaction scrim only. Pass loadingOverride when your upstream has its own loading signal.

Default skeleton — real header, column-aligned skeleton rows.

Loading…

loadingVariant="spinner" — centered spinner under the same header (Ant-style).

Loading…
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";

/**
 * First-load states: the real column header always stays; only the body
 * switches — skeleton rows (default) or a centered spinner
 * (`loadingVariant="spinner"`).
 */
export function DataTableLoadingStateDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: { kind: "text", label: "Order #", size: 110 },
          customer: { kind: "text", label: "Customer", size: 140 },
          status: {
            kind: "status",
            label: "Status",
            variants: STATUS_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  return (
    <div className="space-y-4">
      <div className="space-y-2">
        <p className="text-muted-foreground text-xs">
          Default skeleton — real header, column-aligned skeleton rows.
        </p>
        <div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
          <DataTable<Order>
            fillHeight
            className="min-h-0"
            schema={schema}
            data={SEED_ORDERS}
            tableCode="docs-dt-loading-skeleton"
            getRowId={(r) => r.id}
            filtering="none"
            disablePagination
            loadingOverride
          />
        </div>
      </div>
      <div className="space-y-2">
        <p className="text-muted-foreground text-xs">
          <code>loadingVariant=&quot;spinner&quot;</code> — centered spinner
          under the same header (Ant-style).
        </p>
        <div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
          <DataTable<Order>
            fillHeight
            className="min-h-0"
            schema={schema}
            data={SEED_ORDERS}
            tableCode="docs-dt-loading-spinner"
            getRowId={(r) => r.id}
            filtering="none"
            disablePagination
            loadingOverride
            loadingVariant="spinner"
          />
        </div>
      </div>
    </div>
  );
}

Empty State

Zero rows render the guided empty state: a muted icon, a reason, and — when filters are active — a clear-filters action. Custom icon / illustration / title / description / action slots are available on <DataTableEmptyState> for bespoke layouts.

No Data

Zero rows in the dataset — centered inbox icon and primary message.

No data yet
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, STATUS_VARIANTS } from "./demo-data";

const TABLE_CODE = "docs-dt-empty-state";

/** Zero rows: default `no-data` empty state (inbox icon + message). */
export function DataTableEmptyStateDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: { kind: "text", label: "Order #", size: 110 },
          customer: { kind: "text", label: "Customer", size: 140 },
          status: {
            kind: "status",
            label: "Status",
            variants: STATUS_VARIANTS,
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Zero rows in the dataset — centered inbox icon and primary message.
      </p>
      <div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
      <DataTable<Order>
        fillHeight
        className="min-h-0"
        schema={schema}
        data={[]}
        tableCode={TABLE_CODE}
        getRowId={(r) => r.id}
        filtering="none"
        disablePagination
        features={{}}
      />
      </div>
    </div>
  );
}

No Matches

Rows exist but the current filters exclude every row — the no-matches variant shows a search icon and a clear-filters link.

Active filter chip with zero matching rows — empty body only, no side panel.

ORD-1001Acme CorpConfirmed
ORD-1002Globex IncShipped
ORD-1003InitechDraft
ORD-1004Umbrella LLCDelivered
ORD-1005Soylent CorpCancelled
ORD-1006Wayne EnterprisesConfirmed
ORD-1007Stark IndustriesShipped
ORD-1008OscorpDraft
ORD-1009Cyberdyne SystemsConfirmed
ORD-1010Wonka IndustriesDelivered
ORD-1011Acme CorpShipped
ORD-1012Globex IncConfirmed
ORD-1013InitechDraft
ORD-1014Umbrella LLCShipped
ORD-1015Soylent CorpConfirmed
ORD-1016Wayne EnterprisesDelivered
ORD-1017Stark IndustriesDraft
ORD-1018OscorpCancelled
ORD-1019Cyberdyne SystemsConfirmed
ORD-1020Wonka IndustriesShipped
"use client";

import { useEffect, useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useDataTable } from "@/components/f-ui/data-table/use-data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";

const TABLE_CODE = "docs-dt-empty-no-matches";

/** Impossible customer substring — reads naturally in the filter chip. */
const NO_MATCH_QUERY = "NoSuchCustomer";

/**
 * Rows exist but an active filter yields zero matches — `no-matches` empty state
 * with SearchX icon and clear-filters action.
 */
export function DataTableEmptyNoMatchesDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: { kind: "text", label: "Order #", size: 110 },
          customer: {
            kind: "text",
            label: "Customer",
            size: 140,
            filter: {
              valueType: "string",
              paramKey: "customer",
              logicVar: "customer",
            },
          },
          status: {
            kind: "status",
            label: "Status",
            variants: STATUS_VARIANTS,
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  const dataTable = useDataTable<Order>({
    schema,
    tableCode: TABLE_CODE,
    data: SEED_ORDERS,
    getRowId: (r) => r.id,
    features: { filters: true },
  });

  useEffect(() => {
    if (dataTable.filters.activeCount === 0) {
      dataTable.filters.setField("customer", {
        op: "contains",
        value: NO_MATCH_QUERY,
      });
    }
  }, [dataTable]);

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Active filter chip with zero matching rows — empty body only, no side
        panel.
      </p>
      <div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          dataTable={dataTable}
          filters={{ surface: "bar" }}
          disablePagination
        />
      </div>
    </div>
  );
}

Sparse Pagination

Few rows per page with many total pages: the footer leads with the item range (1–10 of 847) so the current slice stays legible, while the ellipsis strip keeps the first and last pages one click away.

ORD-0001Acme CorpConfirmed$2,499.00
ORD-0002Globex IncShipped$849.50
ORD-0003InitechDraft$120.00
ORD-0004Umbrella LLCDelivered$3,200.00
ORD-0005Soylent CorpCancelled$45.99
ORD-0006Wayne EnterprisesConfirmed$18,750.00
ORD-0007Stark IndustriesShipped$5,600.00
ORD-0008OscorpDraft$299.99
ORD-0009Cyberdyne SystemsConfirmed$14,200.00
ORD-0010Wonka IndustriesDelivered$89.00
1–10 of 847
Rows per page
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";

const TABLE_CODE = "docs-dt-sparse-pagination";

/** 847 rows at 10 per page: range summary (`1–10 of 847`) + ellipsis strip. */
export function DataTableSparsePaginationDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            size: 110,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 140,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 100,
          },
        },
        {},
      ),
    [],
  );

  const rows = useMemo<Order[]>(
    () =>
      Array.from({ length: 847 }, (_, i) => {
        const seed = SEED_ORDERS[i % SEED_ORDERS.length]!;
        return {
          ...seed,
          id: `sparse-${i + 1}`,
          orderNumber: `ORD-${String(i + 1).padStart(4, "0")}`,
        };
      }),
    [],
  );

  return (
    <div className="h-[min(480px,68vh)] min-h-[280px] overflow-hidden rounded-md border">
      <DataTable<Order>
        fillHeight
        className="min-h-0"
        schema={schema}
        data={rows}
        tableCode={TABLE_CODE}
        getRowId={(r) => r.id}
        filtering="none"
        defaultPageSize={10}
        pageSizeOptions={[10, 20, 50]}
        features={{ pagination: { mode: "offset" } }}
      />
    </div>
  );
}

Filters & URL State

The Filters and shareable URLs example above shows builder filters with URL sync. This section documents builder layout (filters={{ surface, chips }}), presets, the command palette, and headless usage.

Builder layout (bar, panel, chips)

Use with filtering="builder". The filters prop tunes builder chrome — not the surface selector (that is filtering).

filters.surfaceUI
"bar"Toolbar add-filter + saved-filters + chips
"panel"Left filter panel
"both"Bar controls + panel
filters.chipsDefault
omitted"infobar" when a surface is enabled
"infobar"Chip row below the toolbar
"inline"Chips in the toolbar (chrome="inline-chips")
"hidden"No chip row
<DataTable
  schema={schema}
  data={rows}
  params={params}
  onParamsChange={setParams}
  filtering="builder"
  filters={{ surface: "bar" }}
/>

Filter Chips (Infobar)

Each active filter appears as a chip: field label, operator, value (when needed), and remove (×). To change columns, remove a chip and use + Filter in the toolbar.

By default the chip row sits below the toolbar (filters={{ surface: "bar" }} or "both" with default chips). Hide chips with filters={{ surface: "panel", chips: "hidden" }} (panel-only demos).

Bar only

Compact table on seed data — chips, + Filter, and clear all; no filter panel.

Default bazza-style bar: chips, add filter, and clear all — no side panel.

ORD-1001Acme CorpConfirmedHigh$2,499.0052026-03-012026-03-01NoElectronicswebExpedite if possible
ORD-1002Globex IncShippedMedium$849.50122026-03-032026-03-03YesApparelphone
ORD-1003InitechDraftLow$120.0022026-03-052026-03-05NoFoodediAwaiting payment confirmation
ORD-1004Umbrella LLCDeliveredMedium$3,200.0012026-02-202026-02-20YesElectronicspos
ORD-1005Soylent CorpCancelledLow$45.9932026-03-102026-03-10NoFoodwebCustomer requested cancellation
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00502026-03-122026-03-12NoElectronicsphoneBulk order — warehouse B
ORD-1007Stark IndustriesShippedHigh$5,600.0082026-03-082026-03-08YesElectronicsedi
ORD-1008OscorpDraftMedium$299.9942026-03-152026-03-15NoHomeposGift wrapping requested
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00202026-03-112026-03-11NoElectronicsweb
ORD-1010Wonka IndustriesDeliveredLow$89.0062026-02-282026-02-28YesFoodphone
ORD-1011Acme CorpShippedMedium$1,340.00102026-03-062026-03-06YesApparelediSecond batch
ORD-1012Globex IncConfirmedLow$210.5032026-03-142026-03-14NoHomepos
ORD-1013InitechDraftUrgent$7,800.00152026-03-162026-03-16NoElectronicswebPending manager approval
ORD-1014Umbrella LLCShippedHigh$450.0022026-03-092026-03-09YesHomephone
ORD-1015Soylent CorpConfirmedMedium$1,100.0072026-03-132026-03-13NoAppareledi
ORD-1016Wayne EnterprisesDeliveredHigh$9,300.00252026-02-152026-02-15YesElectronicsposRepeat order
ORD-1017Stark IndustriesDraftLow$65.0012026-03-172026-03-17NoFoodweb
ORD-1018OscorpCancelledMedium$2,100.0092026-03-022026-03-02NoApparelphoneDuplicate order
ORD-1019Cyberdyne SystemsConfirmedUrgent$32,000.001002026-03-182026-03-18NoElectronicsediPriority shipment required
ORD-1020Wonka IndustriesShippedLow$175.0042026-03-042026-03-04YesFoodpos
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { SEED_ORDERS, type Order } from "@/demos/data-table/demo-data";
import { createOrdersFilterSurfaceSchema } from "@/demos/data-table/filter-surface-demos-schema";

const TABLE_CODE = "docs-dt-filter-bar-only";

export function DataTableFilterBarOnlyDemo() {
  const schema = useMemo(() => createOrdersFilterSurfaceSchema(), []);
  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Default <strong>bazza-style</strong> bar: chips, add filter, and clear
        all — no side panel.
      </p>
      <div className="h-[min(360px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={SEED_ORDERS}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          filters={{ surface: "bar" }}
          disablePagination
        />
      </div>
    </div>
  );
}

Filter Panel (Zoho-style)

A 280px sticky-header / sticky-footer left sidebar. Field rows expand to registry-driven value editors (valueType + presentation). Apply batches changes inside the panel and flushes them in one shot. Edits made elsewhere (chips, preset apply) commit immediately to the same applied state.

<DataTable
  schema={schema}
  data={rows}
  params={params}
  onParamsChange={setParams}
  filtering="builder"
  filters={{ surface: "panel" }}
  filterPanelProps={{
    title: "Filter Deals By",
    sections: [
      {
        id: "fields",
        label: "Field Filters",
        content: { kind: "fields", fieldKeys: "*" },
      },
    ],
  }}
/>

Panel only

Bar hidden; filters are edited and applied from the sidebar.

Zoho-style panel only: stage field rows, then Apply Filter. The toolbar bar is hidden.

ORD-1001Acme CorpConfirmedHigh$2,499.0052026-03-012026-03-01NoElectronicswebExpedite if possible
ORD-1002Globex IncShippedMedium$849.50122026-03-032026-03-03YesApparelphone
ORD-1003InitechDraftLow$120.0022026-03-052026-03-05NoFoodediAwaiting payment confirmation
ORD-1004Umbrella LLCDeliveredMedium$3,200.0012026-02-202026-02-20YesElectronicspos
ORD-1005Soylent CorpCancelledLow$45.9932026-03-102026-03-10NoFoodwebCustomer requested cancellation
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00502026-03-122026-03-12NoElectronicsphoneBulk order — warehouse B
ORD-1007Stark IndustriesShippedHigh$5,600.0082026-03-082026-03-08YesElectronicsedi
ORD-1008OscorpDraftMedium$299.9942026-03-152026-03-15NoHomeposGift wrapping requested
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00202026-03-112026-03-11NoElectronicsweb
ORD-1010Wonka IndustriesDeliveredLow$89.0062026-02-282026-02-28YesFoodphone
ORD-1011Acme CorpShippedMedium$1,340.00102026-03-062026-03-06YesApparelediSecond batch
ORD-1012Globex IncConfirmedLow$210.5032026-03-142026-03-14NoHomepos
ORD-1013InitechDraftUrgent$7,800.00152026-03-162026-03-16NoElectronicswebPending manager approval
ORD-1014Umbrella LLCShippedHigh$450.0022026-03-092026-03-09YesHomephone
ORD-1015Soylent CorpConfirmedMedium$1,100.0072026-03-132026-03-13NoAppareledi
ORD-1016Wayne EnterprisesDeliveredHigh$9,300.00252026-02-152026-02-15YesElectronicsposRepeat order
ORD-1017Stark IndustriesDraftLow$65.0012026-03-172026-03-17NoFoodweb
ORD-1018OscorpCancelledMedium$2,100.0092026-03-022026-03-02NoApparelphoneDuplicate order
ORD-1019Cyberdyne SystemsConfirmedUrgent$32,000.001002026-03-182026-03-18NoElectronicsediPriority shipment required
ORD-1020Wonka IndustriesShippedLow$175.0042026-03-042026-03-04YesFoodpos
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { SEED_ORDERS, type Order } from "@/demos/data-table/demo-data";
import { createOrdersFilterSurfaceSchema } from "@/demos/data-table/filter-surface-demos-schema";

const TABLE_CODE = "docs-dt-filter-panel-only";

export function DataTableFilterPanelOnlyDemo() {
  const schema = useMemo(() => createOrdersFilterSurfaceSchema(), []);
  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        <strong>Zoho-style</strong> panel only: stage field rows, then{" "}
        <strong>Apply Filter</strong>. The toolbar bar is hidden.
      </p>
      <div className="h-[min(360px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={SEED_ORDERS}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          disablePagination
          filters={{ surface: "panel", chips: "hidden" }}
          filterPanelProps={{ title: "Filter orders" }}
        />
      </div>
    </div>
  );
}

Bar + Panel

Use filtering="builder" with filters={{ surface: "both" }}: the panel stages edits until Apply; chips reflect applied state and can still open the value editor.

Bar and panel together: new filters are added from the panel; chip edits still commit immediately.

ORD-1001Acme CorpConfirmedHigh$2,499.0052026-03-012026-03-01NoElectronicswebExpedite if possible
ORD-1002Globex IncShippedMedium$849.50122026-03-032026-03-03YesApparelphone
ORD-1003InitechDraftLow$120.0022026-03-052026-03-05NoFoodediAwaiting payment confirmation
ORD-1004Umbrella LLCDeliveredMedium$3,200.0012026-02-202026-02-20YesElectronicspos
ORD-1005Soylent CorpCancelledLow$45.9932026-03-102026-03-10NoFoodwebCustomer requested cancellation
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00502026-03-122026-03-12NoElectronicsphoneBulk order — warehouse B
ORD-1007Stark IndustriesShippedHigh$5,600.0082026-03-082026-03-08YesElectronicsedi
ORD-1008OscorpDraftMedium$299.9942026-03-152026-03-15NoHomeposGift wrapping requested
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00202026-03-112026-03-11NoElectronicsweb
ORD-1010Wonka IndustriesDeliveredLow$89.0062026-02-282026-02-28YesFoodphone
ORD-1011Acme CorpShippedMedium$1,340.00102026-03-062026-03-06YesApparelediSecond batch
ORD-1012Globex IncConfirmedLow$210.5032026-03-142026-03-14NoHomepos
ORD-1013InitechDraftUrgent$7,800.00152026-03-162026-03-16NoElectronicswebPending manager approval
ORD-1014Umbrella LLCShippedHigh$450.0022026-03-092026-03-09YesHomephone
ORD-1015Soylent CorpConfirmedMedium$1,100.0072026-03-132026-03-13NoAppareledi
ORD-1016Wayne EnterprisesDeliveredHigh$9,300.00252026-02-152026-02-15YesElectronicsposRepeat order
ORD-1017Stark IndustriesDraftLow$65.0012026-03-172026-03-17NoFoodweb
ORD-1018OscorpCancelledMedium$2,100.0092026-03-022026-03-02NoApparelphoneDuplicate order
ORD-1019Cyberdyne SystemsConfirmedUrgent$32,000.001002026-03-182026-03-18NoElectronicsediPriority shipment required
ORD-1020Wonka IndustriesShippedLow$175.0042026-03-042026-03-04YesFoodpos
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { SEED_ORDERS, type Order } from "@/demos/data-table/demo-data";
import { createOrdersFilterSurfaceSchema } from "@/demos/data-table/filter-surface-demos-schema";

const TABLE_CODE = "docs-dt-filter-bar-panel";

export function DataTableFilterBarPanelDemo() {
  const schema = useMemo(() => createOrdersFilterSurfaceSchema(), []);
  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Bar and panel together: new filters are added from the panel; chip edits
        still commit immediately.
      </p>
      <div className="h-[min(380px,55vh)] min-h-[280px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={SEED_ORDERS}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          disablePagination
          filtering="builder"
          filterPanelProps={{ title: "Filter orders" }}
        />
      </div>
    </div>
  );
}

Saved Filters (presets)

A dropdown rendered in the panel header lists user-saved presets and a footer Save filter button captures the current panel draft—staged rows you have edited but not yet applied—together with any panel criteria state, as a new preset. The dropdown supports inline rename and delete via a per-row menu. Persistence falls back to localStorage keyed by tableCode when no remote callbacks are provided.

<DataTable
  schema={schema}
  data={rows}
  params={params}
  onParamsChange={setParams}
  filtering="builder"
  filters={{ surface: "panel" }}
  initialFilterPresets={[
    {
      id: "preset-shipped",
      name: "Shipped orders",
      filterModel: { shipped: { op: "is_true" } },
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    },
  ]}
  filterPanelProps={{
    title: "Filter Orders By",
    enableSavedFilters: true, // defaults to true once any preset exists
  }}
  onFilterPresetSave={async (preset) => {
    await fetch("/api/presets", { method: "POST", body: JSON.stringify(preset) });
  }}
  onFilterPresetDelete={async (id) => {
    await fetch(`/api/presets/${id}`, { method: "DELETE" });
  }}
/>

Live demo

Saved presets in the panel header (seeded below). New saves persist to localStorage keyed by tableCode.

ORD-1001Acme CorpConfirmedHigh$2,499.0052026-03-012026-03-01NoElectronicswebExpedite if possible
ORD-1002Globex IncShippedMedium$849.50122026-03-032026-03-03YesApparelphone
ORD-1003InitechDraftLow$120.0022026-03-052026-03-05NoFoodediAwaiting payment confirmation
ORD-1004Umbrella LLCDeliveredMedium$3,200.0012026-02-202026-02-20YesElectronicspos
ORD-1005Soylent CorpCancelledLow$45.9932026-03-102026-03-10NoFoodwebCustomer requested cancellation
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00502026-03-122026-03-12NoElectronicsphoneBulk order — warehouse B
ORD-1007Stark IndustriesShippedHigh$5,600.0082026-03-082026-03-08YesElectronicsedi
ORD-1008OscorpDraftMedium$299.9942026-03-152026-03-15NoHomeposGift wrapping requested
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00202026-03-112026-03-11NoElectronicsweb
ORD-1010Wonka IndustriesDeliveredLow$89.0062026-02-282026-02-28YesFoodphone
ORD-1011Acme CorpShippedMedium$1,340.00102026-03-062026-03-06YesApparelediSecond batch
ORD-1012Globex IncConfirmedLow$210.5032026-03-142026-03-14NoHomepos
ORD-1013InitechDraftUrgent$7,800.00152026-03-162026-03-16NoElectronicswebPending manager approval
ORD-1014Umbrella LLCShippedHigh$450.0022026-03-092026-03-09YesHomephone
ORD-1015Soylent CorpConfirmedMedium$1,100.0072026-03-132026-03-13NoAppareledi
ORD-1016Wayne EnterprisesDeliveredHigh$9,300.00252026-02-152026-02-15YesElectronicsposRepeat order
ORD-1017Stark IndustriesDraftLow$65.0012026-03-172026-03-17NoFoodweb
ORD-1018OscorpCancelledMedium$2,100.0092026-03-022026-03-02NoApparelphoneDuplicate order
ORD-1019Cyberdyne SystemsConfirmedUrgent$32,000.001002026-03-182026-03-18NoElectronicsediPriority shipment required
ORD-1020Wonka IndustriesShippedLow$175.0042026-03-042026-03-04YesFoodpos
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { SEED_ORDERS, type Order } from "@/demos/data-table/demo-data";
import { createOrdersFilterSurfaceSchema } from "@/demos/data-table/filter-surface-demos-schema";

const TABLE_CODE = "docs-dt-filter-saved-presets";

export function DataTableFilterSavedPresetsDemo() {
  const schema = useMemo(() => createOrdersFilterSurfaceSchema(), []);
  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Saved presets in the panel header (seeded below). New saves persist to{" "}
        <code>localStorage</code> keyed by <code>tableCode</code>.
      </p>
      <div className="h-[min(400px,55vh)] min-h-[280px] overflow-hidden rounded-md border">
        <DataTable<Order>
          fillHeight
          className="min-h-0"
          schema={schema}
          data={SEED_ORDERS}
          tableCode={TABLE_CODE}
          getRowId={(r) => r.id}
          disablePagination
          filters={{ surface: "panel", chips: "hidden" }}
          initialFilterPresets={[
            {
              id: "preset-shipped",
              name: "Shipped orders",
              filterModel: {
                shipped: { op: "is_true" },
              },
              createdAt: new Date("2026-04-01").toISOString(),
              updatedAt: new Date("2026-04-01").toISOString(),
            },
            {
              id: "preset-pending",
              name: "Pending high priority",
              filterModel: {
                status: {
                  op: "in",
                  values: ["draft", "confirmed"],
                },
                priority: {
                  op: "in",
                  values: ["high", "urgent"],
                },
              },
              createdAt: new Date("2026-04-05").toISOString(),
              updatedAt: new Date("2026-04-05").toISOString(),
            },
          ]}
          filterPanelProps={{
            title: "Filter orders",
            enableSavedFilters: true,
          }}
        />
      </div>
    </div>
  );
}

Filter panel (Apply & Clear)

The sidebar uses a panel-local draft: changes are staged until Apply Filter commits them to the table filter state (and URL when enabled). Clear removes all active field filters and applies that immediately (Zoho-style one-shot clear), without a separate apply step for the reset.

Persistence (collapsed/open)

The filter panel’s collapsed vs open state is persisted to localStorage by default when <DataTable> supplies a stable tableCode. The storage key is f-ui:filter-panel-collapsed:{tableCode}. Pass filterPanelCollapsePersistence on filterPanelProps to replace local storage with an async load / save pair (for example a backend preference API).

<DataTable
  schema={schema}
  data={rows}
  params={params}
  onParamsChange={setParams}
  tableCode="orders"
  filtering="builder"
  filters={{ surface: "panel" }}
  filterPanelProps={{
    defaultCollapsed: false,
    // persistFilterPanelCollapsed: false, // opt out — memory only
    // filterPanelCollapseStorageKey: "f-ui:filter-panel-collapsed:orders-secondary",
    // filterPanelCollapsePersistence: {
    //   load: () => fetch("/api/prefs/filter-panel").then((r) => r.json()),
    //   save: (collapsed) =>
    //     fetch("/api/prefs/filter-panel", {
    //       method: "PUT",
    //       body: JSON.stringify({ collapsed }),
    //     }).then(() => undefined),
    // },
  }}
/>

Command palette

The field-picker command palette is opt-in: set enableCommandPalette to mount the dialog and register the keyboard shortcut (commandPaletteHotkey: "cmdf" | "slash" | "none"; default "cmdf"). The toolbar does not include an “Add filter” button; use the filter bar’s add control, the filter panel, or the shortcut.

<DataTable
  schema={schema}
  data={rows}
  pagination={pagination}
  enableCommandPalette
  commandPaletteHotkey="cmdf"
/>

Wire format and operators are defined in contract/DIALECT.md (fui-query/1). presentation is client-only and is not sent on the wire.

Standalone Usage With useTableFilters

The headless hook powers custom layouts that are not wrapped in <DataTable>. Compile fields from the schema, back state with URL params when needed, and pass the same filters object to both surfaces:

import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { compileTableFilterFields } from "@/components/f-ui/data-table/filter/compile-table-filter-fields";
import { useTableFilters } from "@/components/f-ui/data-table/filter/use-table-filters";
import { DataTableFilterBar } from "@/components/f-ui/data-table/data-table-parts/data-table-filter-bar";
import { DataTableFilterPanel } from "@/components/f-ui/data-table/data-table-parts/data-table-filter-panel";

const fields = compileTableFilterFields(schema);
const { params, setParams } = useTableSearchParams(schema);

const filters = useTableFilters({
  fields,
  params,
  setParams,
  onAppliedChange: (_applied, jsonLogic) => refetch(jsonLogic),
});

return (
  <>
    <DataTableFilterPanel filters={filters} />
    <DataTableFilterBar filters={filters} variant="chips-only" />
  </>
);

Headless layout

Same hook as inside <DataTable>, wired to both surfaces in a custom layout (no table).

No filters applied. Use the panel or chips to start filtering.
"use client";

import { useMemo } from "react";
import { useState } from "react";

import { DataTableFilterBar } from "@/components/f-ui/data-table/data-table-parts/data-table-filter-bar";
import { DataTableFilterPanel } from "@/components/f-ui/data-table/data-table-parts/data-table-filter-panel";
import { dataTableChromeRow } from "@/components/f-ui/data-table/lib/chrome-surfaces";
import { cn } from "@/lib/utils";
import { compileTableFilterFields } from "@/components/f-ui/data-table/filter/compile-table-filter-fields";
import { useTableFilters } from "@/components/f-ui/data-table/filter/use-table-filters";
import { createOrdersFilterSurfaceSchema } from "@/demos/data-table/filter-surface-demos-schema";

export function DataTableFiltersStandaloneDemo() {
  const schema = useMemo(() => createOrdersFilterSurfaceSchema(), []);
  const fields = useMemo(() => compileTableFilterFields(schema), [schema]);
  const [params, setParamsState] = useState<Record<string, unknown>>({});

  const filters = useTableFilters({
    fields,
    params,
    setParams: (updates) => setParamsState((prev) => ({ ...prev, ...updates })),
  });

  return (
    <div className="space-y-2">
      <p className="text-muted-foreground text-xs">
        Same hook as inside <code>&lt;DataTable&gt;</code>, wired to both
        surfaces in a custom layout (no table).
      </p>
      <div className="border-border grid max-h-[320px] grid-cols-[minmax(0,260px)_1fr] gap-0 overflow-hidden rounded-md border">
        <DataTableFilterPanel filters={filters} title="Filter orders" />
        <div className="flex min-h-0 flex-col">
          <div className={cn(dataTableChromeRow, "border-b")}>
            <DataTableFilterBar filters={filters} variant="chips-only" />
          </div>
          <div className="bg-background text-muted-foreground min-h-0 flex-1 overflow-auto p-3 text-xs">
            {filters.activeCount === 0
              ? "No filters applied. Use the panel or chips to start filtering."
              : `${filters.activeCount} filter${filters.activeCount === 1 ? "" : "s"} applied.`}
          </div>
        </div>
      </div>
    </div>
  );
}

Row & Batch Actions

Row actions menu (three-dot dropdown), right-click context menu, batch action bar with selection checkboxes. "Delete" shows a destructive confirmation dialog.

Select rows for batch actions. Use the kebab menu in the trailing column for per-row actions.

Actions
ORD-1001Acme CorpConfirmedHigh$2,499.00
ORD-1002Globex IncShippedMedium$849.50
ORD-1003InitechDraftLow$120.00
ORD-1004Umbrella LLCDeliveredMedium$3,200.00
ORD-1005Soylent CorpCancelledLow$45.99
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00
ORD-1007Stark IndustriesShippedHigh$5,600.00
ORD-1008OscorpDraftMedium$299.99
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00
ORD-1010Wonka IndustriesDeliveredLow$89.00
ORD-1011Acme CorpShippedMedium$1,340.00
ORD-1012Globex IncConfirmedLow$210.50
ORD-1013InitechDraftUrgent$7,800.00
ORD-1014Umbrella LLCShippedHigh$450.00
ORD-1015Soylent CorpConfirmedMedium$1,100.00
ORD-1016Wayne EnterprisesDeliveredHigh$9,300.00
ORD-1017Stark IndustriesDraftLow$65.00
ORD-1018OscorpCancelledMedium$2,100.00
ORD-1019Cyberdyne SystemsConfirmedUrgent$32,000.00
ORD-1020Wonka IndustriesShippedLow$175.00
20 rows
Rows per page
"use client";

import { useMemo } from "react";
import {
  EyeIcon,
  PencilIcon,
  Trash2Icon,
  TruckIcon,
  XCircleIcon,
} from "lucide-react";
import { toast } from "sonner";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import type {
  BatchAction,
  RowAction,
} from "@/components/f-ui/data-table/schema/types";
import {
  type Order,
  PRIORITY_VARIANTS,
  SEED_ORDERS,
  STATUS_VARIANTS,
} from "./demo-data";

export function DataTableActionsDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            pinned: "left",
            size: 120,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 160,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 110,
          },
          priority: {
            kind: "status",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 120,
          },
        },
        {},
      ),
    [],
  );

  const { params, setParams } = useTableSearchParams(schema, {
    urlParamPrefix: "dt_ac_",
  });

  const rowActions = useMemo<RowAction<Order>[]>(
    () => [
      {
        id: "view",
        label: "View details",
        icon: <EyeIcon className="size-4" />,
        onClick: (row) => toast.info(`View: ${row.orderNumber}`),
      },
      {
        id: "edit",
        label: "Edit",
        icon: <PencilIcon className="size-4" />,
        onClick: (row) => toast.success(`Edit: ${row.orderNumber}`),
      },
      {
        id: "cancel",
        label: "Cancel order",
        icon: <XCircleIcon className="size-4" />,
        variant: "destructive",
        onClick: (row) => toast.warning(`Cancel: ${row.orderNumber}`),
        disabled: (row) =>
          row.status === "cancelled" || row.status === "delivered",
      },
    ],
    [],
  );

  const batchActions = useMemo<BatchAction<Order>[]>(
    () => [
      {
        id: "ship",
        label: "Mark shipped",
        icon: <TruckIcon className="size-4" />,
        onClick: ({ rows, count }) => {
          toast.success(`Shipping ${rows.length || count} order(s)`);
        },
        disabled: (rows) => {
          const allShipped = rows.every((r) => r.shipped);
          return allShipped ? "All selected orders are already shipped" : false;
        },
      },
      {
        id: "delete",
        label: "Delete",
        icon: <Trash2Icon className="size-4" />,
        variant: "destructive",
        confirmBeforeRun: true,
        onClick: ({ rows, count }) => {
          toast.success(`Deleted ${rows.length || count} order(s)`);
        },
      },
    ],
    [],
  );

  return (
    <div className="space-y-3">
      <p className="text-muted-foreground text-xs">
        Select rows for batch actions. Use the kebab menu in the trailing
        column for per-row actions.
      </p>
      <DataTable<Order>
        fillHeight
        className="h-[min(520px,75vh)] min-h-[280px]"
        schema={schema}
        data={SEED_ORDERS}
        getRowId={(r) => r.id}
        tableCode="docs-dt-actions"
        params={params}
        onParamsChange={setParams}
        rowActions={rowActions}
        batchActions={batchActions}
        filtering="none"
        features={{ selection: true }}
        showDensityControl
      />
    </div>
  );
}

Inline Edit integration

Editable columns with an edit config render through DataTableEditableCell, which composes Inline Edit for "cell" and "table" strategies (see Editing strategies above). Row strategy uses the same editor factory for the active row without per-cell Inline Edit chrome.

Column Manager

Toggle column visibility, drag-reorder, and pin columns using the column manager button in the toolbar.

Use the columns button in the toolbar to toggle visibility, reorder, and pin columns.

ORD-1001Acme CorpConfirmedHigh$2,499.0052026-03-01NoElectronicsExpedite if possible
ORD-1002Globex IncShippedMedium$849.50122026-03-032026-03-07YesApparel
ORD-1003InitechDraftLow$120.0022026-03-05NoFoodAwaiting payment confirmation
ORD-1004Umbrella LLCDeliveredMedium$3,200.0012026-02-202026-02-25YesElectronics
ORD-1005Soylent CorpCancelledLow$45.9932026-03-10NoFoodCustomer requested cancellation
ORD-1006Wayne EnterprisesConfirmedUrgent$18,750.00502026-03-12NoElectronicsBulk order — warehouse B
ORD-1007Stark IndustriesShippedHigh$5,600.0082026-03-082026-03-14YesElectronics
ORD-1008OscorpDraftMedium$299.9942026-03-15NoHomeGift wrapping requested
ORD-1009Cyberdyne SystemsConfirmedHigh$14,200.00202026-03-11NoElectronics
ORD-1010Wonka IndustriesDeliveredLow$89.0062026-02-282026-03-04YesFood
ORD-1011Acme CorpShippedMedium$1,340.00102026-03-062026-03-12YesApparelSecond batch
ORD-1012Globex IncConfirmedLow$210.5032026-03-14NoHome
ORD-1013InitechDraftUrgent$7,800.00152026-03-16NoElectronicsPending manager approval
ORD-1014Umbrella LLCShippedHigh$450.0022026-03-092026-03-13YesHome
ORD-1015Soylent CorpConfirmedMedium$1,100.0072026-03-13NoApparel
ORD-1016Wayne EnterprisesDeliveredHigh$9,300.00252026-02-152026-02-22YesElectronicsRepeat order
ORD-1017Stark IndustriesDraftLow$65.0012026-03-17NoFood
ORD-1018OscorpCancelledMedium$2,100.0092026-03-02NoApparelDuplicate order
ORD-1019Cyberdyne SystemsConfirmedUrgent$32,000.001002026-03-18NoElectronicsPriority shipment required
ORD-1020Wonka IndustriesShippedLow$175.0042026-03-042026-03-10YesFood
"use client";

import { useMemo } from "react";

import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
  PRIORITY_VARIANTS,
  CATEGORY_VARIANTS,
} from "./demo-data";

export function DataTableColumnManagerDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            pinned: "left",
            size: 120,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 160,
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 110,
          },
          priority: {
            kind: "status",
            label: "Priority",
            sortKey: "priority",
            variants: PRIORITY_VARIANTS,
            size: 100,
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 120,
          },
          quantity: {
            kind: "number",
            label: "Qty",
            sortKey: "quantity",
            decimals: 0,
            size: 80,
          },
          orderDate: {
            kind: "date",
            label: "Order Date",
            sortKey: "orderDate",
            size: 120,
          },
          shippedDate: {
            kind: "date",
            label: "Shipped",
            sortKey: "shippedDate",
            size: 120,
          },
          shipped: {
            kind: "boolean",
            label: "Shipped?",
            trueLabel: "Yes",
            falseLabel: "No",
            size: 90,
          },
          category: {
            kind: "label",
            label: "Category",
            sortKey: "category",
            variants: CATEGORY_VARIANTS,
            size: 110,
          },
          notes: { kind: "text", label: "Notes", size: 200 },
        },
        {},
      ),
    [],
  );

  return (
    <div className="space-y-3">
      <p className="text-muted-foreground text-xs">
        Use the columns button in the toolbar to toggle visibility, reorder, and
        pin columns.
      </p>
      <DataTable<Order>
        fillHeight
        className="h-[min(520px,75vh)] min-h-[280px]"
        schema={schema}
        data={SEED_ORDERS}
        getRowId={(r) => r.id}
        tableCode="docs-dt-columns"
        filtering="none"
        showDensityControl
        features={{ columns: { manager: true } }}
        disablePagination
      />
    </div>
  );
}

Server-Shaped + JSON Logic

buildJsonLogicFilter converts URL params into a JSON Logic rule for POST { filter } list endpoints. The preview panel shows the live output.

adapter request.filter →
null (no active filters)
Loading…
No rows
Rows per page
"use client";

import { useCallback, useMemo, useState } from "react";
import { parseAsInteger } from "nuqs";
import { useQueryClient } from "@tanstack/react-query";

import { createInMemoryListAdapter } from "@/components/f-ui/data-table/adapters/in-memory-list-adapter";
import type {
  JsonLogicRule,
  ListQueryAdapter,
} from "@/components/f-ui/data-table/adapters/list-query-contract";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
  type Order,
  SEED_ORDERS,
  STATUS_VARIANTS,
  CATEGORY_VARIANTS,
} from "./demo-data";

export function DataTableServerJsonLogicDemo() {
  const schema = useMemo(
    () =>
      defineTableSchema<Order>(
        {
          orderNumber: {
            kind: "text",
            label: "Order #",
            sortKey: "orderNumber",
            pinned: "left",
            size: 120,
          },
          customer: {
            kind: "text",
            label: "Customer",
            sortKey: "customer",
            size: 160,
            filter: {
              valueType: "string",
              paramKey: "customer",
              logicVar: "customer",
            },
          },
          status: {
            kind: "status",
            label: "Status",
            sortKey: "status",
            variants: STATUS_VARIANTS,
            size: 110,
            filter: {
              valueType: "enum",
              paramKey: "status",
              logicVar: "status",
              options: Object.entries(STATUS_VARIANTS).map(
                ([value, { label }]) => ({
                  value,
                  label,
                }),
              ),
            },
          },
          amount: {
            kind: "currency",
            label: "Amount",
            sortKey: "amount",
            currency: "USD",
            size: 120,
            filter: {
              valueType: "number",
              paramKey: "amount",
              logicVar: "amount",
              defaultOperator: "between",
            },
          },
          category: {
            kind: "label",
            label: "Category",
            sortKey: "category",
            variants: CATEGORY_VARIANTS,
            size: 110,
            filter: {
              valueType: "enum",
              paramKey: "category",
              logicVar: "category",
              options: Object.entries(CATEGORY_VARIANTS).map(
                ([value, { label }]) => ({
                  value,
                  label,
                }),
              ),
            },
          },
        },
        {
          filterGroups: [
            {
              label: "Filters",
              fields: ["customer", "status", "amount", "category"],
            },
          ],
        },
      ),
    [],
  );

  const { params, setParams } = useTableSearchParams(schema, {
    urlParamPrefix: "dt_jl_",
    extraParsers: {
      pageSize: parseAsInteger.withDefault(10),
    },
  });

  const baseAdapter = useMemo(
    () => createInMemoryListAdapter<Order>({ items: SEED_ORDERS }),
    [],
  );

  const [latestFilter, setLatestFilter] = useState<JsonLogicRule | null>(null);

  // Pedagogical shim: capture each adapter call's `request.filter` into local
  // state so the debug panel below can render the live JSON-Logic payload.
  const adapter = useCallback<ListQueryAdapter<Order>>(
    async (request) => {
      setLatestFilter(request.filter ?? null);
      return baseAdapter(request);
    },
    [baseAdapter],
  );

  const queryClient = useQueryClient();

  return (
    <div className="space-y-3">
      <div className="flex flex-wrap gap-2">
        <button
          type="button"
          className="border-input bg-background hover:bg-muted/50 rounded-md border px-2 py-1 text-xs"
          onClick={() => {
            queryClient.invalidateQueries({
              queryKey: ["data-table", "docs-dt-json-logic"],
            });
          }}
        >
          Refetch
        </button>
      </div>
      <div className="border-input bg-muted/30 rounded-md border p-3 font-mono text-[11px] leading-relaxed">
        <div className="text-muted-foreground mb-1">
          adapter request.filter →
        </div>
        <pre className="text-foreground max-h-32 overflow-auto whitespace-pre-wrap break-all">
          {latestFilter == null
            ? "null (no active filters)"
            : JSON.stringify(latestFilter, null, 2)}
        </pre>
      </div>
      <DataTable<Order>
        fillHeight
        className="h-[min(520px,75vh)] min-h-[280px]"
        schema={schema}
        adapter={adapter}
        getRowId={(r) => r.id}
        tableCode="docs-dt-json-logic"
        params={params}
        onParamsChange={setParams}
        filtering="builder"
        showDensityControl
      />
    </div>
  );
}

On this page