|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/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 useReactTable instance and skips DataTable entirely.

Basic

Columns are plain ColumnDef objects — @42/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

Rows per page

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/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

Rows per page

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

Rows per page

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

Rows per page

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

Rows per page

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

Rows per page

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

Rows per page

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

Rows per page

1

Code

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

Pagination

DataTable renders a pagination footer automatically — a page-size Select, prev/next, and a page-number indicator whose tooltip reveals the row range and total, 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).

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

Rows per page

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

Rows per page

1

Code

<DataTable  columns={columns}  data={/* 24 users */}  pageSizeOptions={[5, 15, 30]}  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

Rows per page

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

Rows per page

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

Rows per page

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, …) — so it composes safely with row selection or a trailing action column instead of stealing their clicks. 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

Rows per page

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.

Rows per page

1

No users match your filters.Some description

Rows per page

1

Rows per page

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

Rows per page

1

Code

function UsersTable() {  const [loading, setLoading] = useState(false);  const refetch = () => {    setLoading(true);    fetchUsers().then(() => setLoading(false));  };  return (    <>      <Button size="sm" variant="light" color="gray" 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/ui-react/data-table) to match the default bar's shimmer/fill/radius.

Rows per page

1

Code

import { DATA_TABLE_SKELETON_CLASS } from '@42/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

Rows per page

1

Ada LovelaceStaff
Alan TuringStudent

Rows per page

1

Ada LovelaceStaff
Alan TuringStudent

Rows per page

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

Rows per page

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

Rows per page

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

Rows per page

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

Rows per page

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/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

Rows per page

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/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.

Subcomponents

Prop

Type

API

Prop

Type

On this page