A headless, component-oriented data grid for TypeScript, with a React table component called Gridwright.
Local arrays and remote endpoints travel one code path. A data source declares which parts of the query it already resolved; the pipeline applies the rest. Moving a grid from an in-memory array to a paginating API is a one-line change at the call site, and nothing else about your component changes.
- Headless core. No DOM, no React, no runtime dependencies.
- Unstyled. Structural CSS driven entirely by custom properties.
- One component. A tree, windowing, row actions, inline editing and icons are options on
<Gridwright />, not separate components, so they compose instead of competing. - Extensible. Sorting, filtering, search and pagination are plugins with no privileged access, so yours reaches exactly as far.
- MIT.
npm install apsw-gridwrightReact 18 or 19 is an optional peer dependency, needed only for apsw-gridwright/react.
git clone https://github.com/yaotzin1/apsw-gridwright && cd apsw-gridwright
npm install
npm run exampleThat serves a playground on http://localhost:5173 running the built package, with a mock API
behind it. Uncheck sort under "the server resolves" and watch the work move from the server to
the in-memory pipeline without the component above it changing. See
examples/playground.
import { Gridwright } from 'apsw-gridwright/react';
import 'apsw-gridwright/styles.css';
const columns = [
{ id: 'name', header: 'Name' },
{ id: 'department', header: 'Department' },
{ id: 'salary', header: 'Salary', align: 'end' as const },
];
export function People({ people }: { people: Person[] }) {
return <Gridwright columns={columns} data={people} pageSize={25} searchable aria-label="People" />;
}Sorting, filtering, search, pagination and the empty state are already there. The grid renders its first page on the first paint, with no loading flash, because an array resolves synchronously and the engine notices.
import { createRestDataSource } from 'apsw-gridwright';
import { Gridwright } from 'apsw-gridwright/react';
const source = createRestDataSource<Person>({ url: '/api/people' });
export function People() {
return <Gridwright columns={columns} dataSource={source} pageSize={25} searchable />;
}The columns, the component and every prop but one are unchanged. You now get request cancellation, out-of-order response rejection, backoff on retryable failures, the server's own error message, and a total the grid refuses to invent.
Your endpoint receives:
GET /api/people?page=1&pageSize=25&sort=salary%3Adesc&search=ada&filters=[...]
page is one-based. Answer with an array, { data, total }, { items, count }, a nested
meta.total, or an X-Total-Count header. All of them are read. Pass buildParams or
parseResponse for anything else.
A data source declares what it resolved for itself:
interface DataSourceCapabilities {
sort: boolean;
filter: boolean;
search: boolean;
paginate: boolean;
}Every facet left false is applied in memory by the pipeline.
| Your source | Declares | The pipeline does |
|---|---|---|
| An array | nothing | filter, search, sort, paginate |
| A full query API | everything | nothing |
| An endpoint that only pages | paginate |
filter, search, sort, on the page received |
That last row is the common real case, and it is why this is a declaration rather than a boolean. Nothing above the pipeline branches on any of it.
import { createRemoteDataSource } from 'apsw-gridwright';
const source = createRemoteDataSource<Person>({
capabilities: { sort: false, filter: false, search: false, paginate: true },
fetcher: async ({ query, signal }) => {
const page = await api.people({ page: query.pagination.pageIndex + 1, signal });
return { rows: page.items, totalRows: page.total };
},
});The engine has no adapter dependency:
import { createGridEngine, createLocalDataSource } from 'apsw-gridwright';
const api = createGridEngine({ columns, dataSource: createLocalDataSource(people) });
api.subscribe((state) => render(state.rows));
api.toggleSort('salary');
api.setFilter('department', { operator: 'eq', value: 'Research' });
api.nextPage();
api.destroy();<Gridwright /> is a default arrangement of parts. When it does not fit, place them yourself:
import {
GridwrightProvider, GridTable, GridHeader, GridBody, GridPagination, useGridwright,
} from 'apsw-gridwright/react';
function PeopleGrid() {
const grid = useGridwright({ columns, data: people, pageSize: 25 });
return (
<GridwrightProvider instance={grid}>
<PageHeader>
<GridPagination pageSizeOptions={[25, 50]} />
</PageHeader>
<GridTable aria-label="People">
<GridHeader />
<GridBody onRowClick={(row) => open(row.data)} />
</GridTable>
</GridwrightProvider>
);
}const columns = [
{ id: 'name', header: 'Name' },
{
id: 'salary',
header: 'Salary',
align: 'end' as const,
formatValue: (value: number) => currency.format(value),
cell: ({ value }) => <strong>{currency.format(value)}</strong>,
},
{
id: 'status',
header: 'Status',
accessor: (row) => (row.active ? 'Active' : 'Inactive'),
cell: ({ value }) => <Badge tone={value === 'Active' ? 'green' : 'grey'}>{value}</Badge>,
},
];formatValue is what search matches against and what the default cell renders, so a reader
searching for what they can see finds it. cell controls only the rendering.
A column's icon is a renderer like cell is, so it is decided per row rather than per column:
{
id: 'name',
header: 'Name',
icon: ({ row }) => (row.kind === 'folder' ? <FolderIcon /> : <FileIcon />),
}The grid puts it before the cell's text and marks it aria-hidden, since the text already says
what it says. On a tree column it lands between the toggle and the label rather than before the
indentation. Size it with --gw-icon and the surrounding font size; nothing is bundled.
The stylesheet is structural. Everything visible is a custom property:
.my-page {
--gw-accent: #7c3aed;
--gw-row-height: 44px;
--gw-border: #e5e7eb;
--gw-radius: 12px;
}Dark mode follows prefers-color-scheme and can be forced either way with
data-gw-theme="dark" or "light" on the grid root. Reduced motion is respected. Add your own
classes through classNames, or skip the stylesheet entirely and style the gw-* classes
yourself.
import { pl } from 'apsw-gridwright/locales';
<Gridwright columns={columns} data={people} locale={pl} />That switches the text, the plural rules, the number formatting and the text direction together.
Bundled packs: en, de, es, fr, pl, behind their own entry point so a bundler drops the
ones you do not import.
Messages are a flat catalog with ICU-style {placeholders} and CLDR plural categories, which is
what i18next, FormatJS, Lingui, Weblate and Crowdin already consume. Plurals come from
Intl.PluralRules, so Polish gets its four forms and Arabic its six without this package shipping
a plural table:
'selection.count': {
zero: 'Nie zaznaczono wierszy',
one: 'zaznaczono {count} wiersz',
few: 'zaznaczono {count} wiersze', // 2-4, 22-24, ...
many: 'zaznaczono {count} wierszy', // 5-21, 25-31, ...
other: 'zaznaczono {count} wiersza',
}Already using an i18n library? Hand it the function it already gives you:
const { t } = useTranslation('grid');
<Gridwright columns={columns} data={people} translate={t} />Or override one string without a catalog:
<Gridwright locale={pl} messages={{ 'status.empty': 'Nie znaleziono pracowników' }} />A key a catalog omits falls back to English, never to the key itself, and auditCatalog fails a
test when a catalog drifts from the key set. Full detail in docs/i18n.md.
The four built-ins are ordinary plugins. Yours has the same reach:
import { corePlugins, createGridEngine, STAGE_ORDER } from 'apsw-gridwright';
import type { GridPlugin } from 'apsw-gridwright';
const activeOnly = <TRow extends { active: boolean }>(): GridPlugin<TRow> => ({
name: 'acme:active-only',
setup: (context) =>
context.registerStage({
id: 'acme:active-only',
order: STAGE_ORDER.FILTER + 1,
capability: 'filter', // skipped when the server already filters
run: (rows) => {
const kept = rows.filter((row) => row.active);
return { rows: kept, totalRows: kept.length };
},
}),
});
createGridEngine({ columns, dataSource, plugins: [...corePlugins(), activeOnly()] });Add one at runtime with api.use(plugin), which returns an unsubscribe that removes it cleanly. A
stage that throws loses its own effect and nothing else: a broken plugin never empties the grid.
Stage slots, in order: PRE, FILTER, SEARCH, SORT, TRANSFORM, PAGINATE, POST.
docs/plugins.md has the rules and worked recipes for grouping, aggregation, query persistence and telemetry. docs/extensibility.md maps every seam and, more usefully, says what is deliberately closed and what to do instead.
Everything below is a prop on the same <Gridwright />. None of them is a different component, and
they compose: a virtualized tree with a row menu and two editable columns is four props.
| Prop | Turns on |
|---|---|
tree={{ getRowId, getChildren }} |
nested rows, expansion, lazy children, optimistic mutation |
virtual |
rendering only the rows on screen, with the pagination footer replaced |
rowActions={[...]} |
a floating menu on the row, opened by hover, click or right-click |
onCellEdit={fn} |
editing in place, on the columns that declare edit |
icon on a column |
a per-row glyph beside the cell's text |
<Gridwright
columns={columns}
data={folders}
tree={{ getRowId: (row) => row.id, getChildren: (row) => row.children }}
virtual
rowActions={[{ id: 'open', label: 'Open', onSelect: open }]}
onCellEdit={(rowId, columnId, value) => save(rowId, columnId, value)}
/>Switching tree on or off remounts the grid, because a tree and a flat list are different grids.
Everything else changes in place.
Rows with children, to any depth, and a row can sit under more than one parent:
<Gridwright
columns={columns}
data={folders}
tree={{
getRowId: (row) => row.id,
getChildren: (row) => row.children,
defaultExpandedDepth: 1,
}}
/>The structure is a nested set, so ancestry is two comparisons, subtree size is arithmetic, and the interval order is already the render order. Filtering keeps the ancestors of a match, sorting orders siblings within each parent, and the total counts visible nodes.
Children can arrive lazily, keyed on the row so a second placement reuses the first fetch:
tree={{
hasChildren: (row) => row.type === 'folder',
loadChildren: async ({ row, signal }) => api.children(row.id, { signal }),
}}Editing, adding and moving are optimistic with rollback. A refused change restores the tree exactly and reports on the row:
tree={{ onCommit: async (change) => api.save(change) }}Every change is a shape a database can take: update carries the row, insert carries the parent
and the index, move carries both parents, remove carries the scope. Store the adjacency list and
each one is a single statement; the nested set the grid works with is derived and should not be
stored. See docs/persistence.md.
Insertion, movement and removal live on the tree controller, which the grid hands back:
tree={{ controllerRef: setController }}await controller.insertRow(row, { referenceNodeId: 'docs', position: 'child' });
await controller.moveNode('docs/cv', { referenceNodeId: 'photos', position: 'child' });TreeGridwright is still exported and is exactly <Gridwright tree={...} />, kept because a tree
is a common enough starting point to deserve a name.
Full detail in docs/tree.md.
virtual renders only the rows on screen. The rest are two spacer rows, so the element stays a
real <table> and keeps its column alignment and its grid semantics:
<Gridwright columns={columns} data={rows} virtual={{ rowHeight: 40, height: 480 }} />That alone handles a large array. It does not handle ten million rows, because holding ten million objects is the problem rather than rendering them. For that, the source holds a window instead of a table:
import { createWindowedDataSource } from 'apsw-gridwright';
const source = createWindowedDataSource({
blockSize: 200,
maxBlocks: 12,
fetchRange: ({ offset, limit, signal }) => api.people({ offset, limit, signal }),
});
<Gridwright columns={columns} dataSource={source} virtual />;The browser then holds blockSize * maxBlocks rows, whatever the total is. Rows whose block has
not arrived render as skeletons, and renderSkeleton replaces them.
Above roughly 400,000 rows the scrolling area would be taller than a browser will render, so past
that the scroll position becomes a ratio over the whole result set rather than a pixel offset. The
visible consequence is that one pixel of scrollbar covers several rows. aria-rowcount and
aria-rowindex carry the true numbers throughout.
Full detail in docs/virtualization.md.
Off by default. A checkbox column nobody asked for is a column the reader has to account for.
<Gridwright
columns={columns}
data={people}
selectionMode="multiple"
onSelectionChange={(ids, rows) => setChosen(rows)}
/>Selected ids survive paging. getSelectedRows() returns only the rows currently loaded, because
rows on another page cannot be resolved to objects.
Give rows a stable identity when they have no id property:
<Gridwright columns={columns} data={people} getRowId={(row) => row.employeeNumber} />When a paginating source answers without a total, state.isTotalExact is false, totalRows and
pageCount become lower bounds, and the range reads "1-25 of many". The grid knows another page
exists and says exactly that, rather than computing a number from one page that the reader would
then act on.
<Gridwright
columns={columns}
dataSource={source}
renderError={(error, retry) => (
<Callout tone="critical">
{error.message}
{error.retryable && <Button onClick={retry}>Try again</Button>}
</Callout>
)}
/>error.message carries the server's own sentence when it sent one. error.retryable is false for
a 404 or a 422, so you are not offering a retry that cannot help.
| Export | What it does |
|---|---|
createGridEngine(options) |
the engine: state, fetching, plugins, selection |
createLocalDataSource(rows) |
an array as a source, with setRows to replace it |
createRemoteDataSource({ fetcher }) |
any async function, with abort handling and backoff |
createRestDataSource({ url }) |
a REST endpoint, with parameters and envelopes handled |
createWindowedDataSource({ fetchRange }) |
holds a window of blocks rather than the whole table |
corePlugins() |
the four built-in stages |
createTranslator({ catalog }) |
the message catalog, outside React |
createTreeController(options) |
expansion, lazy children, optimistic mutations |
createTreeDataSource(source, controller) |
turns any source into one that answers with nodes |
treePlugins({ controller }) |
the tree stage plus pagination |
buildTreeIndex(rows, shape) |
the nested set on its own, with no grid attached |
auditCatalog(messages) |
the keys a catalog is missing, for a test |
STAGE_ORDER |
the stage slots |
WINDOW_OFFSET_META |
the meta key carrying where the held rows start |
computeVirtualWindow(input) |
which rows a scroll position is asking for, with no framework |
scrollOffsetForIndex(input) |
the offset that brings a row into view, its inverse |
GridwrightError |
throw this from a source to control the message and retry advice |
getState, subscribe, on, getColumns, setColumns, setDataSource, setQuery, setSort,
toggleSort, getSort, setFilters, setFilter, getFilter, setSearch, setPage,
nextPage, previousPage, setPageSize, setSelectionMode, toggleRowSelection,
setSelectedIds, selectPage, clearSelection, isSelected, getSelectedRows, use,
refresh, destroy.
state:change, query:change, fetch:start, fetch:success, fetch:error, fetch:settled,
selection:change, plugin:error.
Gridwright, useGridwright, GridwrightProvider, useGridwrightContext, useTranslator,
GridToolbar, GridTable, GridHeader, GridBody, GridCell, GridPagination,
defaultLabels, labelsFrom, mergeLabels.
Windowing: GridVirtualBody, useVirtualRows.
Tree: TreeGridwright, useTreeGridwright, TreeProvider, useTreeContext, useNodeState,
TreeCell, reactTreeColumns, rowDataOf.
Adapter plugins: BubbleMenu, InlineEditProvider, editableColumns, useInlineEdit.
apsw-gridwright/locales exports en, de, es, fr, pl.
The grid is a real <table> with role="grid". Sort controls are buttons, reachable by Tab and
activated by Enter or Space. Sort state is announced through aria-sort on the header cell.
Loading, empty and error states render inside the table so the header and column widths hold still.
A live region announces loading, and errors use role="alert".
Variable row heights under virtualization, column resize and reorder, grouping and aggregation, drag-and-drop reparenting, and cascading selection down a subtree. Adapters for frameworks other than React are possible against the same core, and none ship yet.
| Page | Covers |
|---|---|
| Tree data | Nested rows, several parents, lazy children, inline editing, the bubble menu |
| Virtualization and windowing | Rendering a window, holding a window, and ten million rows |
| Storing what the reader changes | Inline edits and tree mutations, and the table behind them |
| Data sources | Capabilities, totals, aborts, retries, writing your own |
| Extensibility | Every seam, and what is closed on purpose |
| Writing a plugin | The rules, plus grouping, aggregation, persistence, telemetry |
| Translation | Catalogs, plurals, direction, wiring an existing i18n library |
| Spec-driven development | How this repository is built |
See CONTRIBUTING.md.
Changes here move through a written eight-stage process, with the rules in workflow.ai.yml and
fifteen skill documents under .agents/. AGENTS.md, GEMINI.md and .claude/skills/ are
generated from that one file, and CI fails when they drift. Two of the stage-3 artifacts, the
public API surface and the lifecycle contract, are frozen during implementation so the engine and
the adapter can be written in parallel without disagreeing.
npm run verify is the gate everything passes through: typecheck, lint, both test suites, the
build, a smoke suite against dist/, and a packaging audit. See
docs/spec-driven-development.md for why.
If this saved you the week it takes to build a grid that handles remote data properly, you can buy me a coffee.
The package stays MIT and zero-dependency either way. Bug reports and locale contributions are worth more than coffee.
MIT © yaotzin1