Skip to content

Headless table engine and React binding

See it working: the live demo — one dataset, one feature set, re-rendered by every real adapter.

Everything in AdaptTable revolves around one idea: a TableSource<T> — a uniform contract that a table consumes regardless of where its rows came from. Both built-in source hooks fulfil it, and the table renders without knowing which produced it.

interface TableSource<TRow> {
rows: readonly TRow[];
total: number;
isLoading: boolean; // FIRST load only — refreshes never re-raise it
isFetching: boolean; // any in-flight request
isFetchingNextPage: boolean; // an append fetch (infinite mode)
hasNextPage: boolean; // more rows can be APPENDED (always false when paged)
fetchNextPage: () => void; // appends; no-op in paged mode
refetch?: () => void; // re-runs the underlying fetch
error: Error | null;
paginationMode: "infinite" | "paged";
// state
page: number;
limit: number;
search: string;
sortBy: string | undefined;
sortDir: "asc" | "desc" | undefined;
extra: Record<string, string | string[] | number | undefined>;
// setters
setPage;
setLimit;
setSort;
setSearch;
setExtra;
setExtras;
clearAll;
}

Because the table is agnostic to the source’s origin, you can switch between in-memory and server data — or build a custom source — without touching the UI.

In-memory. Filters by a searchable-text projector, sorts by a column’s sortValue (or a custom getSortValue), and slices for the current page.

const source = useFrontendData({ data, columns, getSearchText, getSortValue });

Server-paginated. Wraps a caller-supplied useInfiniteQuery hook and maps each page to rows via selectPage. Flattens pages in infinite mode, returns the latest page in paged mode, and clamps out-of-range pages.

const source = useQuerySource({ usePaginatedQuery, selectPage, baseParams });
interface ColumnDef<TRow> {
key: string; // unique; also the backend sortBy value
header: ReactNode; // pre-translated
Cell?: ComponentType<{ row: TRow; rowIndex: number }>; // stable identity
accessor?: (row: TRow) => ReactNode; // lightweight
sortValue?: (row: TRow) => string | number | boolean | null | undefined;
sortable?: boolean;
width?: number | string;
align?: "start" | "center" | "end";
mobileLabel?: string;
hideOnMobile?: boolean;
hideOnDesktop?: boolean;
}

"auto" (the default) resolves to infinite scroll on mobile and paged on desktop, using the same breakpoint the table uses, so the two never drift. Force a mode with paginationMode: "paged" | "infinite".

In infinite mode the adapters auto-load the next page when the bottom of the list scrolls into view (via IntersectionObserver, prefetching ~200px early), and also render an explicit Load more button as a keyboard- and screen-reader-friendly fallback. The auto-load behaviour is packaged as a headless hook, useInfiniteScroll, exported from @adapttable/core — attach the returned ref to a sentinel element after your last row to get the same behaviour in custom markup:

const sentinelRef = useInfiniteScroll({
hasNextPage: source.hasNextPage,
isFetchingNextPage: source.isFetchingNextPage,
fetchNextPage: source.fetchNextPage,
itemCount: source.rows.length, // re-arms so short pages keep loading
enabled: source.paginationMode === "infinite",
});
// …render rows…
<div ref={sentinelRef} />;

It is SSR- and test-safe: where IntersectionObserver is unavailable it no-ops, leaving the Load more button as the path forward.

Long infinite lists can opt into row/card windowing with virtualize. The core exports useTableVirtualization, and the ready adapters wire it into their desktop rows and mobile cards. With no maxHeight the window tracks the page scroll; add maxHeight and the same prop virtualizes inside the scroll box instead — fifty thousand rows in a 380px panel stay a handful of DOM nodes. Ant Design maps the same virtualize() feature to antd’s native virtual table mode.

<DataTable
source={source}
columns={columns}
rowKey={(row) => row.id}
paginationMode="infinite"
virtualize
estimateRowSize={56}
estimateCardSize={140}
/>

Virtualization is optional. Leave it off for small lists or paged tables; turn it on for long infinite lists.

Adapters automatically switch from table rows to mobile cards at the shared mobile breakpoint. hideOnMobile can hide low-value columns, while mobileIdentityColumns preserves a configurable number of leading desktop-visible columns so every card keeps enough identity to be useful.

<DataTable mobileIdentityColumns={2} />

Filtering, sorting, paging and grouping are decisions about data, not about the DOM. In v3 they live in @adapttable/core as a plain object with no framework in its import graph, and @adapttable/react is the binding that subscribes a component tree to one.

import { createTableEngine } from "@adapttable/core";
const engine = createTableEngine({
data: people,
columns: [{ key: "name", sortable: true }],
rowKey: (row) => row.id,
defaults: { limit: 25 },
});
engine.dispatch({ type: "setSearch", search: "ada" });
engine.rows("page"); // the rows that page shows

CreateTableEngineOptions is what you build one from. data, columns, rowKey, locale, paginationMode, filterFn and getSearchText stay live — change one and the engine follows. tableId and defaults are read once, because an identity and a seed cannot be retroactively different.

TableEngine is the handle:

Member What it gives you
snapshot() A TableSnapshot: the current query state, the page it is showing, and the four revision counters.
rows(scope) A TableRowScope"page", "visible" or "full".
dispatch(operation) A TableOperation a person performed: setSort, setSearch, setPage, setLimit, setFilters, setGroupBy, setSelection.
configure(patch) A TableEngineConfigPatch — controlled state a binding replays. Pass { silent: true } to move without waking subscribers.
invalidate(axes, next) Tell it the data or columns changed. invalidate(["data"]) with no new array re-derives from the rows it already holds.
subscribe(axes, listener) Wake on a TableRevisionAxisdata, view, schema or policy — and nothing else.
cellValue / rowByKey / rowKey / getColumn Read one cell, find a row, take a row’s identity, look up a column.

TableRevisions carries those four counters. They are separate so a consumer can wake on the one it cares about: a virtualizer on data, a toolbar on view, an agent on policy.

snapshot().page is the page actually on screen. Ask for page 9 of a table that shrank to three and it reports the last page, while requestedPage remembers what you asked for — so restoring the rows restores the page, instead of stranding a reader on page 1.

Handing the engine to something that is not a table

Section titled “Handing the engine to something that is not a table”

createNeutralTable(engine, tableId, binding) wraps one as a NeutralTable: the same rows and revisions plus an operations map saying which of them are actually wired right now. That is the shape @adapttable/ai reads, and the NeutralTableBinding is how a host tells it what the surrounding UI can do. An operation that stops being wired disappears from the map, so nothing is offered a capability the table can no longer perform.

  1. Batteries-includedimport { DataTable } from "@adapttable/<kit>".
  2. Headlessimport { useDataTable } from "@adapttable/react" and render your own markup with the returned prop-getters.