|Kit
DataTable

DataTable

Smart, data-driven table on TanStack Table — sorting, filtering, pagination, selection, and a responsive stacked-card mode, rendered through the Table primitive.

DataTable wraps TanStack Table (headless row/column engine) and renders through the styled Table primitive. Define columns once, hand it data, and sorting / filtering / pagination round-trip as react-table-native state — the same state a useState dispatch or a TanStack Query queryKey can bind to directly, with no kit-specific adapter in between.

import { DataTable } from "@42-central/ui-react/data-table";

const columns = [
  { accessorKey: "name", header: "Name", enableHiding: false },
  { accessorKey: "role", header: "Role" },
  { accessorKey: "commits", header: "Commits", meta: { align: "end" } },
];

<DataTable columns={columns} data={users} />;

Every column is sortable by default (click the header) — no extra wiring. A power user who wants total control composes the Table parts with their own useTable instance and skips DataTable entirely — register dataTableFeatures (exported from @42-central/ui-react/data-table) on it so the instance satisfies the types DataTable.Pagination and friends expect.

Basic

Columns are plain ColumnDef objects — @42-central/ui-react/data-table re-exports createColumnHelper for full type narrowing in a real app, but a plain object literal works identically at runtime (as below). meta.align right-aligns a numeric column.

Ada LovelaceStaff128
Alan TuringStudent57
Grace HopperStaff203

1

Code

const columns = [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'role', header: 'Role' },
  { accessorKey: 'commits', header: 'Commits', meta: { align: 'end' } },
];

const users = [
{ id: '1', name: 'Ada Lovelace', role: 'Staff', commits: 128 },
{ id: '2', name: 'Alan Turing', role: 'Student', commits: 57 },
{ id: '3', name: 'Grace Hopper', role: 'Staff', commits: 203 },
];

<DataTable columns={columns} data={users} />

Resizable columns

By default (sizing="fixed") every column renders at a real, stable width — no more reflow when a filter or sort changes the visible rows. The underlying <table> is table-layout: fixed, sized off react-table's own size / minSize (plain ColumnDef fields — a column with neither renders at react-table's 150px default, a real behavior change from DataTable's initial release, where columns sized themselves from whatever row content happened to be visible). Set enableColumnResizing to let the end user drag a column's trailing edge to their own preference — the handle wires react-table's built-in mouse and touch handler, and supports ArrowLeft / ArrowRight when focused. To persist resized widths across reloads, own columnSizing yourself — the kit's useLocalStorage hook (also exported from @42-central/ui-react) returns a [value, setValue] pair that plugs straight into columnSizing / onColumnSizingChange, keeping every open tab on the same value in sync too. Set sizing="auto" to opt back into content-based sizing instead — see below.

fillLastColumn (on by default) stretches the last visible column to absorb any width left over once every other column has its own size, so the table fills its container instead of leaving a gap when the column sizes add up to less than the available space — the two previews above already have it on (widen your browser to see the Commits column stretch). Set it to false to give the last column its own size/minSize back, gap and all — its resize handle also returns, since there's a real width for the drag to adjust again.

Ada LovelaceStaff128
Alan TuringStudent57
Grace HopperStaff203

1

Code

const columns = [
  { accessorKey: 'name', header: 'Name', size: 200, minSize: 120 },
  { accessorKey: 'role', header: 'Role', size: 120 },
  { accessorKey: 'commits', header: 'Commits', meta: { align: 'end' } },
];

const [columnSizing, setColumnSizing] = useLocalStorage({
key: 'users-table',
defaultValue: {},
});

<DataTable
  columns={columns}
  data={users}
  enableColumnResizing
  columnSizing={columnSizing}
  onColumnSizingChange={setColumnSizing}
/>

Content-based sizing

sizing="auto" opts back into the browser's own table-layout: auto — columns size themselves from whatever rows are currently visible, size / minSize are ignored, and enableColumnResizing has no effect (there's no fixed width for a drag to adjust). This is DataTable's original pre-resize behavior: reach for it only when a table's content is short and uniform enough that reflow-on-filter isn't a real concern, since that's the exact tradeoff sizing="fixed" (the default) exists to avoid.

Ada LovelaceStaff128
Alan TuringStudent57
Grace HopperStaff203

1

Code

<DataTable columns={columns} data={users} sizing="auto" />

Column filters

Set meta.filter on a column to add a header filter affordance — an icon button opening a popover whose body matches the discriminant: text (debounced), select / multi-select, number, date, or date-range. number is a plain min/max (from/to) range: fill either or both, and the filter derives whether that's "at least," "at most," or "between" — no operator to pick. date / date-range open the same calendar + presets(+ time) popover DatePicker itself uses — see Date and date-range below. DataTable also wires the matching client-side filterFn automatically from meta.filter.type, so getFilteredRowModel filters correctly without manualFiltering. An active filter shows a dot indicator, not just a color change.

Ada LovelaceStaff128
Alan TuringStudent57
Grace HopperStaff203
Katherine JohnsonStaff84

1

Code

const columns = [
  { accessorKey: 'name', header: 'Name', meta: { filter: { type: 'text', placeholder: 'Search name…' } } },
  {
    accessorKey: 'role',
    header: 'Role',
    meta: { filter: { type: 'select', multiple: true, options: ['Staff', 'Student'] } },
  },
  {
    accessorKey: 'commits',
    header: 'Commits',
    meta: { align: 'end', filter: { type: 'number', min: 0, max: 2000 } },
  },
];

<DataTable columns={columns} data={users} />
Filtering (and sorting) resets pagination back to page 0 automatically — but only when it's this header/filter/search UI doing the changing. See Controlled state for the one case where you need to reset it yourself.

Single select

Omit multiple for a single-value picker — the popover closes itself the instant a value is picked, matching Select's own close-on-pick habit (multi-select stays open so more values can be toggled).

Ada LovelaceStaff
Alan TuringStudent
Grace HopperStaff

1

Code

const columns = [
  { accessorKey: 'name', header: 'Name' },
  {
    accessorKey: 'role',
    header: 'Role',
    meta: { filter: { type: 'select', options: ['Staff', 'Student'] } },
  },
];

<DataTable columns={columns} data={users} />

Date and date-range

date / date-range open a popover with the same calendar grid + presets rail (+ time row under withTime) DatePicker itself uses — composed directly from its exported Root/Content/Presets/Calendar/TimePanelRow parts, not a separate implementation. A click picks the range's start, a second click its end. date's stored value still derives an operator from that pair — a lone click is "on or after," two clicks is "between" — while date-range always treats the pair as a plain inclusive range, each bound independently optional. Because the calendar's clicks are ordered (start, then end), there's no interaction that produces an upper-bound-only "on or before" value the way the old two-independent-inputs UI could — that operator is still fully supported, just settable only programmatically via controlled columnFilters, not through this popover.

presets (mirroring DatePicker's own prop, on "Hired" below) adds a shortcuts sidebar — true for a mode-appropriate default set, or a custom DatePreset[]. withTime (on "Last review") adds an embedded time row and compares exact instants rather than whole calendar days.

Ada Lovelace2021-03-152024-01-10T09:15:00
Alan Turing2022-07-012024-06-22T16:45:00
Grace Hopper2019-11-202023-09-05T11:00:00

1

Code

const columns = [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'hired', header: 'Hired', meta: { filter: { type: 'date', presets: true } } },
  {
    accessorKey: 'lastReview',
    header: 'Last review',
    meta: { filter: { type: 'date-range', withTime: true } },
  },
];

<DataTable columns={columns} data={users} />

Global search, column visibility & a title bar

title and toolbarActions render a Table.Toolbar above the table; enableGlobalFilter adds a debounced search box bound to globalFilter, and enableColumnVisibility adds a checkbox menu of the table's columns. The bar only renders when at least one of these is set.

A column with enableHiding: false still appears in that menu (and in the stacked layout's own "Columns" section), checked and disabled — it's listed so the end user can see it exists, just can't be turned off. For a utility/action column that was never a real visibility choice in the first place (a trailing "view" button, say), set meta.hideFromMenu instead — it's excluded from both lists entirely, independent of enableHiding.

Users

Ada LovelaceStaff128
Alan TuringStudent57
Grace HopperStaff203

1

Code

<DataTable
  columns={columns}
  data={users}
  title="users"
  toolbarActions={<Button size="sm">Create user</Button>}
  enableGlobalFilter
  enableColumnResizing
  enableColumnVisibility
/>

Non-text headers (meta.label)

When a column's header isn't plain text — an icon, say — meta.label supplies the text used everywhere a label would otherwise fall back to a string header: the column-visibility menu above, and a stacked card's label in responsive="stack" below.

Ada LovelaceVerified
Alan TuringPending

1

Code

<DataTable
  columns={[
    { accessorKey: 'name', header: 'Name' },
    {
      accessorKey: 'verified',
      header: () => <ShieldCheckIcon className="size-4" />,
      meta: { label: 'Verified' },
      cell: ({ getValue }) => (
        <Badge color={getValue() ? 'green' : 'neutral'} size="sm">
          {getValue() ? 'Verified' : 'Pending'}
        </Badge>
      ),
    },
  ]}
  data={users}
  enableColumnVisibility
/>

Pagination

DataTable renders a pagination footer automatically — a page-size Select, prev/next, and a current-page indicator, all driven off getPaginationRowModel() for client-side data (the default). Flip manualPagination for server-owned pages (see Query integration below). Set enablePagination={false} to hide the footer entirely; override its wording with translations (see API below).

By default (paginationMode="page") the indicator shows the page number, with the row range and total revealed in a tooltip on hover or focus. paginationMode="rows" mirrors that — the row range and total are always visible, with the page number moved into the tooltip instead — see Always-visible row range below.

Contributor 1Staff7
Contributor 2Student14
Contributor 3Student21
Contributor 4Staff28
Contributor 5Student35

1

Code

<DataTable
  columns={columns}
  data={/* 24 users */}
  initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}
/>

Custom page sizes

pageSizeOptions replaces the default [10, 20, 50] choices in the page-size Select.

Contributor 1Staff7
Contributor 2Student14
Contributor 3Student21
Contributor 4Staff28
Contributor 5Student35

1

Code

<DataTable
  columns={columns}
  data={/* 24 users */}
  pageSizeOptions={[5, 15, 30]}
  initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}
/>

Always-visible row range

paginationMode="rows" renders the row range and total inline instead of the page number, with no hover or focus needed — and announces it via role="status" as the page changes. The page number isn't lost: it moves into the tooltip, the mirror image of the default. Useful when that count is information the table exists to answer, not a detail to reveal on request.

Contributor 1Staff7
Contributor 2Student14
Contributor 3Student21
Contributor 4Staff28
Contributor 5Student35

1 to 5 of 24

Code

<DataTable
  columns={columns}
  data={/* 24 users */}
  paginationMode="rows"
  initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}
/>

Unknown total (cursor-style)

Omit rowCount (and set pageCount={-1}, react-table's own "unknown" sentinel) for a cursor-backed source that can't say how many pages there are — the footer degrades to "Page N" with prev/next-only, gated by hasNextPage instead of a last-page jump.

Contributor 1
Contributor 2
Contributor 3
Contributor 4
Contributor 5

1

Code

function CursorTable() {
  const [pageIndex, setPageIndex] = useState(0);
  const pageSize = 5;
  const page = fetchCursorPage(pageIndex, pageSize); // your own cursor source
  return (
    <DataTable
      columns={columns}
      data={page.items}
      manualPagination
      pageCount={-1}
      hasNextPage={page.hasMore}
      pagination={{ pageIndex, pageSize }}
      onPaginationChange={(updater) => {
        const next = typeof updater === 'function' ? updater({ pageIndex, pageSize }) : updater;
        setPageIndex(next.pageIndex);
      }}
    />
  );
}

Row selection

enableRowSelection auto-injects a leading checkbox column — an indeterminate select-all in the header, a per-row checkbox with a visually-hidden accessible name. Selected rows tint from the --c-soft slot var.

Ada LovelaceStaff128
Alan TuringStudent57
Grace HopperStaff203

1

Code

<DataTable columns={columns} data={users} enableRowSelection />

Conditional selection

enableRowSelection also takes a predicate — (row) => boolean — to disable selecting some rows instead of all-or-nothing. The header checkbox's indeterminate/select-all state only ever accounts for the currently selectable rows.

Ada LovelaceStaff128
Alan TuringStudent57
Grace HopperStaff203

1

Code

<DataTable
  columns={columns}
  data={users}
  enableRowSelection={(row) => row.original.role === 'Staff'}
/>

Row click

onRowClick fires unless the click landed on a nested interactive element (a link, button, checkbox, menu, …), on content a cell portals out of the row (its own Menu or Dialog), or ended a text selection over the row — so it composes safely with row selection, a trailing action column and copying a cell's value instead of stealing their clicks. Only the row's own content is checked, so the table can sit inside a Tabs panel or a dialog. It's a mouse-only convenience, though: rows aren't themselves focusable, so onRowClick should never be a row's only affordance — pair it with a real link or button, as the "View" column below does.

Ada LovelaceStaff
Alan TuringStudent
Grace HopperStaff

1

Click a row (or its View button).

Code

function UsersTable() {
  const [selected, setSelected] = useState(null);
  return (
    <>
      <DataTable
        columns={[
          { accessorKey: 'name', header: 'Name' },
          { accessorKey: 'role', header: 'Role' },
          {
            id: 'view',
            header: '',
            cell: ({ row }) => (
              <Button size="xs" variant="subtle" onClick={() => setSelected(row.original)}>
                View
              </Button>
            ),
          },
        ]}
        data={users}
        onRowClick={setSelected}
      />
      <Text size="sm" c="muted">
        {selected ? `Selected: ${selected.name}` : 'Click a row (or its View button).'}
      </Text>
    </>
  );
}

Loading, empty & error states

loading shows skeleton rows on first load, then dims existing rows on a refetch (pairs with TanStack Query's keepPreviousData). empty and error replace the body — error is a role="alert" region, and onRetry is a real callback for its "Retry" button, not decoration.

1

No users match your filters.Some description

1

1

Code

<div className="flex w-full flex-col gap-6">
  <DataTable
    columns={[
      { accessorKey: "name", header: "Name" },
      { accessorKey: "role", header: "Role" },
    ]}
    data={[]}
    loading
  />
  <DataTable
    columns={[
      { accessorKey: "name", header: "Name" },
      { accessorKey: "role", header: "Role" },
    ]}
    data={[]}
    empty={{
      title: "No users match your filters.",
      description: "Some description",
    }}
  />
  <ErrorRetryDemo />
</div>

Refetching

Setting loading while data already has rows dims the existing rows instead of swapping in skeletons — the treatment a keepPreviousData-style refetch gets, versus the from-empty first load above.

Ada LovelaceStaff
Alan TuringStudent
Grace HopperStaff

1

Code

function UsersTable() {
  const [loading, setLoading] = useState(false);
  const refetch = () => {
    setLoading(true);
    fetchUsers().then(() => setLoading(false));
  };
  return (
    <>
      <Button size="sm" variant="light" color="neutral" onClick={refetch} loading={loading}>
        Refetch
      </Button>
      <DataTable columns={columns} data={users} loading={loading} />
    </>
  );
}

Column-specific skeleton content (meta.skeleton)

The default skeleton bar is sized to match a plain text cell at the table's current density automatically, with no per-column setup — both the scroll-table and stacked-card views render it through the shared Skeleton component. A column whose real cell isn't text — a thumbnail, an icon button — sets meta.skeleton to override it with a same-shaped placeholder instead, so the row doesn't visibly resize once real data replaces the skeleton. Build it from DATA_TABLE_SKELETON_CLASS (also exported from @42-central/ui-react/data-table) to match the default bar's shimmer/fill/radius.

1

Code

import { DATA_TABLE_SKELETON_CLASS } from '@42-central/ui-react/data-table';

<DataTable
  columns={[
    { accessorKey: 'name', header: 'Name' },
    {
      accessorKey: 'avatar',
      header: '',
      meta: {
        align: 'center',
        skeleton: <div className={cn(DATA_TABLE_SKELETON_CLASS, 'size-8 rounded-full')} />,
      },
    },
  ]}
  data={[]}
  loading
/>

Presentation

size, striped, and stickyHeader forward straight to the underlying Table primitive.

Density

Ada LovelaceStaff
Alan TuringStudent

1

Ada LovelaceStaff
Alan TuringStudent

1

Ada LovelaceStaff
Alan TuringStudent

1

Code

<DataTable columns={columns} data={users} size="xs" />
<DataTable columns={columns} data={users} size="md" />
<DataTable columns={columns} data={users} size="xl" />

Striped rows + sticky header

stickyHeader pins the header row while the body scrolls — it (and stickyFooter) need a bounded height on the table (or Table.Content) to have anything to stick within. stickyFooter is the same idea for a Table.Foot totals row, but DataTable doesn't render a footer row itself (no column footer def, no foot slot yet) — reach for Table's own composition if you need one.

Contributor 1Staff
Contributor 2Student
Contributor 3Student
Contributor 4Staff
Contributor 5Student
Contributor 6Student
Contributor 7Staff
Contributor 8Student
Contributor 9Student
Contributor 10Staff

1

Code

<DataTable columns={columns} data={users} striped stickyHeader className="max-h-64" />

fillLastColumn={false}

Off, the last column keeps its own size/minSize — and its resize handle — instead of stretching to absorb the container's leftover width (contrast with the resizing previews above, which all leave this on).

Ada LovelaceStaff128
Alan TuringStudent57

1

Code

<DataTable
  columns={columns}
  data={users}
  enableColumnResizing
  fillLastColumn={false}
/>

Slot overrides (classNames)

Every part of the render tree — root, table, head, headerRow, headerCell, body, row, cell, empty, error, toolbar, pagination — takes its own class through classNames, merged with (not replacing) the part's own styling.

Ada LovelaceStaff
Alan TuringStudent

1

Code

<DataTable
  columns={columns}
  data={users}
  classNames={{
    headerRow: 'bg-brand-50 dark:bg-brand-950',
    row: 'hover:bg-brand-50/50 dark:hover:bg-brand-950/50',
  }}
/>

Responsive: stacked cards

responsive="stack" renders each row as a Card of label: value pairs below a container-query breakpoint (@lg), instead of the <table> row — useful when columns can't reasonably fit a narrow viewport. Both renderings exist in the DOM; only one is visible at a given container width. This is an explicit accessibility tradeoff: stacked cards drop the row/column semantics screen-reader users rely on, so "scroll" (the default) stays the SR-faithful path. The example below is wrapped in a narrow container to force the stacked view regardless of your screen width.

A stacked table has no header row, so a "Table options" button appears above the cards whenever there's a sortable column, a filterable column, or (with enableColumnVisibility) a hideable column — it opens a bottom Drawer with the same sort toggles, filter controls, and column-visibility checkboxes the desktop header/toolbar expose, consolidated into one mobile-friendly editor.

Users

Name

Ada Lovelace

Role

Staff

Commits

128

Name

Alan Turing

Role

Student

Commits

57

1

Code

<DataTable columns={columns} data={users} responsive="stack" enableColumnVisibility />

Schema-derived columns

Rather than re-declaring an entity's shape by hand, derive it from a schema you already validate against. columnHelperFor targets the vendor-neutral Standard Schema spec — implemented by Zod v4, Valibot, and ArkType — so the kit depends on the spec, never on any one validator. Type inference only: v1 ships no runtime row validation.

import { z } from "zod";
import { columnHelperFor, DataTable } from "@42-central/ui-react/data-table";

const UserSchema = z.object({
  id: z.string(),
  email: z.email(),
  age: z.number(),
  role: z.enum(["student", "staff"]),
});

const col = columnHelperFor(UserSchema);
//    ^ ColumnHelper<{ id: string; email: string; age: number; role: 'student' | 'staff' }>

const columns = [
  col.accessor("email", { header: "Email" }),
  col.accessor("role", {
    header: "Role",
    meta: { filter: { type: "select", options: ["student", "staff"] } },
  }),
];

// Passing `schema` on DataTable constrains `data` / `columns` to the same inferred type end-to-end.
<DataTable schema={UserSchema} columns={columns} data={rows} />;

Controlled state

Every state slice — sorting, filters, globalFilter, pagination, rowSelection, columnVisibility, columnSizing — takes the same controlled value / onChange shape, so a plain useState round-trips exactly like the uncontrolled default above. One live proof (sorting

  • pagination together, with the raw state printed below) stands in for all seven — the UI is identical either way, only the wiring changes. See Query integration right below for the full real-world recipe (server-owned state via manualSorting / manualFiltering / manualPagination, plus mapping the state into an API's own query params).
Contributor 1Staff7
Contributor 2Student14
Contributor 3Student21
Contributor 4Staff28
Contributor 5Student35

1

{
  "sorting": [],
  "pagination": {
    "pageIndex": 0,
    "pageSize": 5
  }
}

Code

function ContributorsTable() {
  const [sorting, setSorting] = useState([]);
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 5 });
  return (
    <DataTable
      columns={columns}
      data={contributors}
      sorting={sorting}
      onSortingChange={setSorting}
      pagination={pagination}
      onPaginationChange={setPagination}
    />
  );
}
Pagination reset only follows DataTable's own controls
Changing sorting, filters, or globalFilter through DataTable's own UI — a header click, a filter popover, the search box — always resets pagination's pageIndex back to 0, so a new sort/filter/search never leaves you stranded on a page that no longer has any rows. If one of those three changes from outside DataTable instead — bypassing onSortingChange / onFiltersChange / onGlobalFilterChange entirely — DataTable has no way to notice, and won't reset pagination for you. Reset pageIndex back to 0 yourself, in that same state update.

Query integration

The kit ships no query-library adapter — DataTable's controlled state (sorting, filters, globalFilter, pagination, …) is react-table-native on both sides, so a plain useState dispatch is a valid onSortingChange etc. Wiring to TanStack Query is a two-line queryKey + queryFn, no kit-specific glue:

"use client";
import { useState } from "react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { DataTable } from "@42-central/ui-react/data-table";
import { columns } from "./columns";

export function UsersTable() {
  const [sorting, setSorting] = useState([]);
  const [filters, setFilters] = useState([]);
  const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 });

  const q = useQuery({
    queryKey: ["users", { sorting, filters, pagination }], // state drops straight in
    queryFn: () => fetchUsers({ sorting, filters, pagination }),
    placeholderData: keepPreviousData, // no flash between pages
  });

  return (
    <DataTable
      columns={columns}
      data={q.data?.rows ?? []}
      rowCount={q.data?.total}
      loading={q.isLoading}
      error={q.isError ? "Failed to load users" : undefined}
      manualSorting
      manualFiltering
      manualPagination
      sorting={sorting}
      onSortingChange={setSorting}
      filters={filters}
      onFiltersChange={setFilters}
      pagination={pagination}
      onPaginationChange={setPagination}
    />
  );
}

For 42's own SortQuery / FilterQuery / paginated-response contract, map the state into query params once per app:

import { match, P } from "ts-pattern";

type ApiFilter = Record<string, unknown>;
type FilterType = "number" | "date" | "date-range" | "text" | string;
type TableFilter = {
  id: string;
  value: unknown;
};

const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === "object" && value !== null;

type NumberFilterValue =
  | { op: "eq" | "gt" | "gte" | "lt" | "lte"; value?: number }
  | { op: "between"; min?: number; max?: number };
type DateFilterValue =
  | { op: "eq" | "gt" | "gte" | "lt" | "lte"; date?: string }
  | { op: "between"; from?: string; to?: string };

const hasNumberOp = (value: unknown): value is NumberFilterValue =>
  isRecord(value) && typeof value.op === "string";
const hasDateOp = (value: unknown): value is DateFilterValue =>
  isRecord(value) && typeof value.op === "string";

const toApiFilter = ({ id, value }: TableFilter, type: FilterType): ApiFilter =>
  match({ type, value })
    .with({ type: "number", value: P.when(hasNumberOp) }, ({ value: v }) =>
      v.op === "between"
        ? {
            ...(v.min != null && { [`${id}[gte]`]: v.min }),
            ...(v.max != null && { [`${id}[lte]`]: v.max }),
          }
        : v.value != null
          ? { [`${id}[${v.op}]`]: v.value }
          : {},
    )
    .with({ type: "date", value: P.when(hasDateOp) }, ({ value: v }) =>
      v.op === "between"
        ? {
            ...(v.from && { [`${id}[gte]`]: v.from }),
            ...(v.to && { [`${id}[lte]`]: v.to }),
          }
        : v.date
          ? { [`${id}[${v.op}]`]: v.date }
          : {},
    )
    .with(
      { type: "date-range", value: P.when(isRecord) },
      ({ value: { from, to } }) => ({
        ...(from && { [`${id}[gte]`]: from }),
        ...(to && { [`${id}[lte]`]: to }),
      }),
    )
    .with({ value: P.array(P.any) }, ({ value }) => ({
      [`${id}[in]`]: value.join(","),
    }))
    .with({ type: "text" }, () => ({
      [`${id}[like]`]: value,
    }))
    .with({ type: P.union("number", "date", "date-range") }, () => ({}))
    .otherwise(() => ({
      [id]: value,
    }));

function toApiQuery(
  {
    sorting,
    filters,
    pagination,
  }: {
    sorting: { id: string; desc: boolean }[];
    filters: TableFilter[];
    pagination: { pageIndex: number; pageSize: number };
  },
  filterType: (id: string) => FilterType,
) {
  const sort = sorting.map(({ id, desc }) => (desc ? `-${id}` : id)).join(",");

  const filter = filters.reduce<ApiFilter>(
    (query, filter) =>
      Object.assign(query, toApiFilter(filter, filterType(filter.id))),
    {},
  );

  return {
    sort,
    filter,
    page: pagination.pageIndex + 1,
    hitsPerPage: pagination.pageSize,
  };
}

// queryFn — the response maps straight back onto DataTable props:
const res = await fetchUsers(toApiQuery(state, typeOf));
return { rows: res.hits, total: res.pagination.nbHits };

page is 1-based (react-table's pageIndex is 0-based); half-open ranges emit only the defined bound. See the RFC for the full contract, including the in-range-only caveat on date-range filters.

Every example above runs against a small in-memory array. For the same contract wired to a real third-party API — server-driven sorting, filtering, and search (synced to the URL) all round-tripping as genuine network requests, real loading/error/retry, and a row-detail Drawer — see the Magic: The Gathering example.

Translations

translations is the one prop for every piece of wording DataTable renders: the pagination footer, the accessible names of the header controls (sort, filter, resize, select-all, column visibility), the empty and error actions, the number filter's placeholders and the stacked layout's options drawer. English defaults ship in the kit, and a partial override only replaces the keys you set. Keys that name a column take a function, so word order stays the translator's call.

Contributeurs

AdaStaff128
GraceStudent64
AlanStudent32

1

Code

const SORT_MODE_FR = { asc: "croissant", desc: "décroissant", none: "aucun" };

<DataTable
  columns={columns}
  data={users}
  enableRowSelection
  enableColumnVisibility
  title="Contributeurs"
  translations={{
    rows: "Lignes par page",
    previousPage: "Page précédente",
    nextPage: "Page suivante",
    items: ({ from, to, hits }) => `${from}–${to} sur ${hits}`,
    selectAllRows: "Tout sélectionner",
    selectRow: (id) => `Sélectionner la ligne ${id}`,
    columnVisibility: "Colonnes affichées",
    triggerLabel: "Afficher les options",
    sortColumn: (label, mode) => `Trier ${label} (${SORT_MODE_FR[mode]})`,
  }}
/>

Row, cell & header test ids

Every row, cell, and header gets a data-testid automatically — from the row's resolved id (getRowId, or react-table's own index-based fallback) and each column's id — with no configuration required:

<DataTable columns={columns} data={users} />
// → data-testid="row-0", "row-0-cell-email", "header-email", …

Override the row scheme with getRowTestId; every cell still derives from whatever it returns. See the Testing guide for the full convention and a Playwright example against a DataTable row.

Subcomponents

Prop

Type

API

Prop

Type

Changelog

v0.19.0
  • translations covers every label: select-all and row checkboxes, sort, filter and resize names, the options menu, filter placeholders, Retry, and the page-size Select and filters it renders

    #331
  • Calendar, DatePicker, DataTable's date filter and Schedule's event details follow IntlProvider's timeZone (prop, then provider, then host) instead of always reading the host zone

    #317
v0.18.2
  • onRowClick fires when the table sits inside an element with a role (a Tabs panel, a dialog), and a click on a cell's role="img" or progressbar content is no longer dropped

    #305
  • A click inside content a cell opens elsewhere on the page (its own Menu or Dialog), or the click that ends a text selection, no longer fires onRowClick

    #305
v0.15.0
  • No more blank band under one or two rows

    #231
v0.12.0
  • Move to react-table v9 (peer 9.2.4); the kit re-exports pre-bound ColumnDef, Row and createColumnHelper

    #182
v0.11.0
  • Header focus rings no longer punch a white halo into the header in light theme

    #161
v0.10.0
  • The "Rows per page" label no longer wraps at narrow footer widths

    #146
v0.5.0
  • paginationMode="rows" keeps the row range always visible and announces it on page change

    #56
v0.4.0
  • The pagination footer shows the page number, with the row range and total moved into a hover/focus tooltip

    #46
  • translations overrides the pagination footer's wording, and enablePagination hides the footer

    #48
  • Header, footer and cell borders and fills derive from the brand color

    #41
v0.3.1
  • Row hover, striped and selected states share one black/white overlay, and header, footer and pagination one gray

    #37
v0.3.0
  • translations overrides the built-in English microcopy, including in DataTable's date filters

    #24
v0.2.0
  • Date and date-range column filters use DatePicker's popover, and accept presets and timeZone

v0.1.3
  • Select column filters accept a custom renderItem

    #15
v0.1.0
  • New DataTable on TanStack Table: sorting, column filters, pagination, row selection and resizable columns

    #11
  • responsive="stack" renders each row as a label:value card below a container-query breakpoint

    #11

On this page