@zaigo/data-list
v0.3.0
Published
One React component for every list you keep rewriting. Sorting, search, a filter builder, pagination, selection, expanding rows, column resize/reorder/pinning, CSV export, virtualization, infinite scroll, drag-to-reorder and saveable state, with zero runt
Maintainers
Readme
data-list
One React component for every list you keep rewriting. Sorting, search, a filter builder, pagination, selection, expanding rows, column resize / reorder / pinning, CSV export, virtualization, infinite scroll, drag-to-reorder and state you can save and restore, with the markup left to you.
Zero runtime dependencies. The table engine, the virtualizer, the CSV writer and the Tailwind class merger are all written here. Installing it adds one entry to your lockfile, not thirty.
npm install @zaigo/data-listreact >= 18 is the only peer dependency. There is nothing else, and you can check that yourself:
npm ls @zaigo/data-list --allMaster prompt for AI assistants
This package is new, so no model has it in training data. Paste the block below into Claude, Cursor, Copilot or any coding assistant, add one line about your own list ("convert the user table in src/pages/Users.tsx to this package"), and it has everything it needs — the API, the setup, the migration order and the traps. No follow-up questions should be necessary.
You are integrating @zaigo/data-list, a zero-dependency React list/table
component, into an existing React project. React >= 18; Next.js App Router,
Pages Router, Vite and plain React are all supported. Everything you need is in
this prompt - do not guess, and do not install any other library.
SETUP
1. npm install @zaigo/data-list
react >= 18 is the only peer. Never add @tanstack/react-table, clsx,
tailwind-merge or any other helper: this package has zero runtime
dependencies by design, and a PR that adds one is wrong.
2. Style it yourself. The package ships no look and no stylesheet.
Pass className / classNames / components. Your app's CSS wins.
Do not import @zaigo/data-list/styles.css (it does not exist).
Do not @source the package.
3. Next.js: import it directly, from Server or Client Components. The bundle
ships a "use client" directive, so the boundary is already handled and no
transpilePackages entry is needed.
CORE USAGE
import { DataList, type DataListColumn } from '@zaigo/data-list'
const columns: DataListColumn<Row>[] = [
{ accessorKey: 'name', header: 'Name' }, // dot paths work: 'user.email'
{ accessorFn: (r) => r.first + ' ' + r.last, id: 'full', header: 'Full name' },
{ accessorKey: 'status', header: 'Status',
cell: ({ row }) => <StatusBadge value={row.original.status} /> },
]
<DataList data={rows} columns={columns} getRowId={(r) => r.id} />
- columns is the data contract: search, sort, filter and CSV export all read
accessor VALUES, never the rendered JSX.
- getRowId: always pass it when rows can be selected, reordered or persisted.
- data is never mutated. Sorting is on by default for accessor columns.
COLUMN OPTIONS
size / minSize / maxSize (defaults 150 / 64 / 720)
sortFn: 'alphanumeric' | 'basic' | 'datetime' | 'text' | custom function
filterFn: 'includesString' | 'equals' | 'inNumberRange' | 'arrIncludes' | custom
enableSorting / enableHiding / enableResizing / enableOrdering /
enablePinning / enableColumnFilter / enableGlobalFilter (all default true)
enableExport default true with accessorKey/accessorFn,
false without. CSV skips false. Use it (or
just omit the accessor) for an Actions column.
meta: anything you want to read back inside a renderer
FEATURES - add only what the task needs
searchable or searchable={{ placeholder, debounce }}
filterable or filterable={{ fields: [{ id, label,
type: 'select'|'text'|'number', options?,
getOptionLabel? }] }}
columnsMenu hide / reorder / pin columns from a menu.
Table view only. Sits beside the filter builder
when filterable is on; otherwise in its own slim
row. Does not require filterable.
exportable or exportable={{ filename }} - CSV of the whole
filtered set in the current sort, accessor values,
formula-injection safe, UTF-8 BOM for Excel.
pagination or pagination={{ pageSize, pageSizeOptions,
alwaysVisible }} - off by default. alwaysVisible
keeps the footer on an empty unfiltered list.
selectable or selectable='single' - checkbox column appears by
itself in table view.
expandable pair with renderExpanded={(ctx) => ...}; not
available inside virtualized layouts.
virtual or virtual={{ height: 600 }} - virtualization, works
in both table and cards.
infinite={{ hasMore, onLoadMore }} use INSTEAD of pagination.
reorderable + onReorder={(next) => setRows(next)} drag handles, keyboard
reachable. Disable sorting on user-ordered lists.
onRowClick={(item) => ...}
loading / error built-in loading and error states. Style them.
empty={{ title, description }} copy for the default empty state.
errorRetry={() => refetch()} Retry button on the default error state.
resizable / pinnable list-wide switches, default true. A column's own
enableResizing / enablePinning wins when set;
then this flag; then the per-column default of true.
persistState={{ key, storage }} 'local' (default) | 'session' | 'url' |
{ read, write }. Ignored when initialState or
onStateChange is also passed (dev warning).
autoCardsBelow={768} with renderItem, cards below this width (px) and
a table above it. views still wins on the wide
side; below the breakpoint cards are forced.
CARD VIEW
Pass renderItem and the same component renders cards instead of a table;
columns are still required (they drive search/sort/filter).
renderItem={({ item, selected, toggleSelected, expanded, toggleExpanded,
dragging, dragHandleProps }) => <YourCard ... />}
views adds a Table/Cards switch (needs renderItem); defaultView picks the
start; cardColumns={1|2|3|4} sets cards per row via inline grid
(steps down on narrow viewports). Style the cards yourself.
onRowClick: table rows stay tabindex rows; cards stay role=listitem
with a sibling role=button overlay (data-dl-card-action), not a
wrapper. renderItem controls must be siblings of that action, never
descendants — a wrapper role=button around a real <button> is wrong.
autoCardsBelow={px} draws cards below that width and a table above it.
SERVER-SIDE DATA
manual={{ rowCount, onQueryChange: ({ pageIndex, pageSize, sorting,
globalFilter, columnFilters }) => refetch(...) }}
The component stops filtering/sorting/paginating locally and reports what the
user asked for. In manual mode filter fields cannot be inferred from data:
give filterable.fields an explicit type, and options for every select.
SAVE AND RESTORE STATE
persistState={{ key: 'projects.view', storage: 'local' }}
storage: 'local' (default) | 'session' | 'url' | { read(key), write(key, state) }
'url' writes compact JSON to a query param named `key` via replaceState.
Denied storage returns null and never crashes render. SSR reads null.
Or do it yourself:
initialState={saved} onStateChange={(s) => save(s)}
The two styles are mutually exclusive: if initialState or onStateChange is
also passed, they win and persistState is ignored (dev-only console.warn).
One JSON-safe object: sorting, search, filters, column widths/order/
visibility/pinning, page, selection, view, cardColumns. initialState is read
once on mount; onStateChange never fires on mount; saved ids for columns that
no longer exist are ignored.
STYLING OVERRIDES
className, rowClassName, toolbar, footer,
classNames={{ root, toolbar, search, filters, table, thead, headerRow,
headerCell, tbody, row, cell, list, item, scroller, pagination, empty,
loading, error, footer, dragHandle, sentinel }}
components={{ SearchInput, Pagination, Empty, Loading, Error }}
renderEmpty / renderLoading / renderError
renderEmpty / renderError win over empty / errorRetry.
HEADLESS
If no built-in layout fits: useDataList({ data, columns, ... }) returns
{ table, rows, searchTerm, setSearchTerm, state, isEmpty, ... } - write your
own markup on top. The engine itself is also exported.
MIGRATING AN EXISTING LIST - work in this order
1. Find the row type and where the data comes from.
2. Write columns from what is currently displayed: one column per field, and
any per-cell JSX moves into that column's cell renderer.
3. If today's markup is a table, let DataList render the table and delete the
old thead/tbody. If today's markup is custom cards or rows, KEEP that exact
JSX and wrap it in renderItem.
4. Replace hand-rolled search/sort/pagination/selection state with the matching
props above, then DELETE the old useState/useEffect/handlers for them. One
source of truth must remain.
5. If the server already pages or sorts, use manual mode - do not fetch
everything to paginate on the client.
6. Do not restyle anything unless asked. Preserve the current look through
cell renderers, renderItem and classNames.
CHECKLIST BEFORE YOU FINISH
- Zero new dependencies in package.json.
- The list is unstyled. Host CSS / classNames cover every slot you care about.
- getRowId is passed wherever selection, reorder or persistence exists.
- The old list's state and effects are deleted, not left beside the new one.Where it runs
| | |
| --- | --- |
| React | 18 and 19. useId puts the floor at 18: a hand-rolled substitute would break SSR hydration, so 17 is genuinely out rather than merely untested. |
| Next.js | App Router and Pages Router. Import it straight into a Server Component: the build ships "use client" as the first line of both bundles, so the boundary is the package's problem, not yours. No transpilePackages entry needed. |
| Vite | Yes, including SSR. |
| Bundlers | ESM and CJS builds. require() and import both work, and the types resolve under moduleResolution of bundler, node16 and node. |
| Browsers | The build targets ES2020. Every evergreen browser since 2020. |
| Node | 18 and up for server rendering. |
None of that is a claim from reading the config. npm run verify packs the tarball, installs it into fresh Next.js, Vite and Node projects, and builds each one. It is what CI runs on every push.
Architecture
The repository carries the full diagram: docs/architecture.svg, and docs/architecture.html is the same drawing with a dark / light toggle and PNG / SVG export. It is linked rather than embedded because npmjs.com resolves README images through the repository, and this repository is private: an embed would render as a broken image for anyone reading the package page.
your app <DataList data columns … />
│
react binding useDataList ── state ──> useTableCore
│
engine core data[] ─> filter ─> sort ─> paginate ─> page rows
(no React import) any stage steps aside under manual*
│
views TableView · ListView · parts (FilterBar, ColumnsMenu, Export…)
│
primitives useVirtualizer · useReorder · useInfiniteScroll · cn · csvYour array goes in at the top and never gets mutated. useDataList holds the state, useTableCore turns it into table / header / row / cell objects, and the engine underneath is plain TypeScript that does not import React at all. Filtering, sorting and pagination run in that order, and any one of them steps aside when you set the matching manual flag because the server already did it.
Quick start
'use client'
import { DataList, type DataListColumn } from '@zaigo/data-list'
type Project = {
id: string
name: string
status: 'active' | 'paused'
users: number
}
const columns: DataListColumn<Project>[] = [
{ accessorKey: 'name', header: 'Project' },
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => (
<span className={row.original.status === 'active' ? 'text-green-600' : 'text-zinc-500'}>
{row.original.status}
</span>
),
},
{ accessorKey: 'users', header: 'Users' },
]
export function Projects({ projects }: { projects: Project[] }) {
return (
<DataList
data={projects}
columns={columns}
getRowId={(project) => project.id}
searchable
selectable
filterable
columnsMenu
exportable
pagination={{ pageSize: 20 }}
onRowClick={(project) => router.push(`/projects/${project.id}`)}
/>
)
}Sorting is on by default for accessor columns. selectable injects a checkbox column in table mode, and that column is furniture: it is never offered for hiding, pinning or reordering.
Styling
The package ships no stylesheet and no default look. Markup, behaviour and classNames / components / className are yours to paint.
<DataList
className="my-list"
classNames={{ table: 'w-full', row: 'border-b', item: 'rounded-xl border p-4' }}
...
/>Do not import @zaigo/data-list/styles.css. It is gone.
Card view
Pass renderItem and the same component stops rendering a table. Columns are still required: they are the data contract that search, filtering and sorting run against, not the presentation.
<DataList
data={projects}
columns={columns}
searchable
cardColumns={3}
renderItem={({ item }) => (
<div className="rounded-xl border p-4">
<h3>{item.name}</h3>
<span>{item.users} users</span>
</div>
)}
/>The same columns array therefore drives a table in one project and cards in another.
Add views to get a Table / Cards switch in the toolbar and let the component own which one is drawn. It only appears when renderItem is given, because without it there is only one view to switch to. defaultView picks the starting side; cardColumns picks how many cards sit across a row (1 to 4, defaulting to 3) and becomes a reader-facing control above the grid when views is on. Narrow viewports step down regardless, via inline grid-template-columns.
onRowClick makes each card an action: the card stays role="listitem" and a sibling overlay with role="button" (data-dl-card-action) is the tab stop. renderItem sits next to that overlay, not inside it, so a real <button> or checkbox in the card is not a nested interactive. Clicking the card body still activates the row; clicking a nested control does not.
autoCardsBelow is the automatic version of that switch: pass a width in pixels and renderItem, and the list draws a table above that width and cards below it. The server paints a table so SSR markup matches the first client frame; a matchMedia subscription corrects it after mount. A views switch still wins on the wide side. Below the breakpoint cards are forced, whichever view the switch is on.
<DataList
data={projects}
columns={columns}
renderItem={({ item }) => <ProjectCard project={item} />}
autoCardsBelow={768}
/>Card view virtualizes by chunking the grid into rows, so a thousand cards cost the same as a thousand table rows.
Filtering
filterable puts a filter builder above the list.
<DataList data={projects} columns={columns} filterable />One search box inside it matches field names and field values at the same time, values first, so typing paused finds the value without you first having to know it lives under Status. Chips apply live, with a running result count, and there is no Apply button to forget.
Fields default to every column with an accessor and filtering enabled. Pass them yourself when the defaults are not what the table shows:
filterable={{
fields: [
{ id: 'status', label: 'Status', type: 'select' },
{ id: 'users', label: 'Users', type: 'number' },
{
id: 'region',
label: 'Region',
type: 'select',
options: [
{ value: 'eu-west-1', label: 'Europe (Ireland)' },
{ value: 'us-east-1', label: 'US East (Virginia)' },
],
},
],
}}A field's type and its list of values are read from the loaded data. That means two things worth knowing:
- A column rendered through
cellexports and filters on its underlying value, because a React node cannot be turned back into text. Where the value and what you drew differ,optionsorgetOptionLabelis how you say so. - Under
manualthe loaded page is a sample, not the value list, so nothing is inferred: every field is a text box unlesstypesays otherwise, and aselectneeds explicitoptions.
Number ranges are parsed against the active locale, so 4.800 means four thousand eight hundred where that is what it means.
Columns: hide, reorder, pin, resize
<DataList data={projects} columns={columns} filterable columnsMenu />columnsMenu adds a Columns button. With filterable it sits beside the filter builder; without it, it gets a slim row of its own. From it a reader can hide a column, move it with the chevrons or by dragging the grip, and pin it to the left edge. Pinned columns collect into their own group and stay put while the rest scrolls sideways. Dragging animates: the rows part around the row you are holding, so the drop position is visible before you let go.
Resizing needs no prop. Drag the right edge of any header. Widths are clamped between minSize (64) and maxSize (720), and the grip tells you when you have hit a bound.
Per column, any of it can be switched off:
{ accessorKey: 'name', header: 'Project', size: 240, enablePinning: false }columnsMenu only appears in table view: there are no columns to arrange in a card grid.
Resizing and pinning can be switched off for the whole list without repeating enableResizing: false on every column:
<DataList data={projects} columns={columns} resizable={false} pinnable={false} />A column that sets enableResizing or enablePinning itself still wins. Precedence is column explicit, then the list-wide flag, then the per-column default of true.
Export
<DataList data={projects} columns={columns} exportable={{ filename: 'projects.csv' }} />Writes every row that survived filtering, in the current sort, with the visible columns that enableExport allows, in their current order. Display-only columns (no accessor) are omitted unless you set enableExport: true. Not the page on screen: the whole filtered set. Values come from the accessors.
Fields are quoted per RFC 4180, the file carries a UTF-8 BOM so Excel opens Turkish and other non-ASCII text correctly, and any text field starting with =, +, - or @ is prefixed with an apostrophe so a spreadsheet treats it as text rather than a formula. Numbers are left alone, so -5 still opens as the number it is.
toCsv and escapeCsvField are exported if you want the string without the button.
Saving and restoring state
Everything the reader changed comes out of onStateChange as one plain object, and goes back in through initialState. Sorting, the search, filter chips, column widths, order, visibility and pinning, the page, the selection, the view and the card count.
const [saved, setSaved] = useState(() =>
JSON.parse(localStorage.getItem('projects.view') ?? 'null'),
)
<DataList
data={projects}
columns={columns}
searchable
filterable
columnsMenu
pagination={{ pageSize: 20 }}
initialState={saved ?? undefined}
onStateChange={(state) => localStorage.setItem('projects.view', JSON.stringify(state))}
/>initialState is read once on mount, like defaultValue on an input, so a parent that rebuilds the object every render does not keep resetting the list. onStateChange never fires on mount, so restoring a state does not immediately write it back out. That matters for a URL-backed consumer, where it would be a history entry nobody asked for.
Most consumers just want the list to remember itself. persistState does that without a parent holding the object and writing it back out:
<DataList
data={projects}
columns={columns}
persistState={{ key: 'projects.view', storage: 'local' }}
/>storage is 'local' by default. 'session' uses sessionStorage. 'url' writes a compact JSON blob into a single query param named key, through history.replaceState — never pushState, so changing a sort does not add a history entry. Pass { read, write } when none of those is the right place.
A denied store (private mode, a blocked iframe, a full quota) returns null from read and is a no-op on write. The list keeps working. On the server read is null and the list renders its defaults.
persistState and a manual initialState / onStateChange pair are mutually exclusive. If either of those is also passed, they win and persistState is ignored, with a development-only console.warn. That is so a parent that already owns the state does not find a second writer behind its back.
The state is plain data on purpose. It round trips through JSON.stringify unchanged, which is what makes a URL, a localStorage key or a saved view per user work at all.
Restoring is not the same as configuring, and props win where the two disagree:
- A column the saved state mentions but
columnsno longer has is ignored, not revived. Adding a column is safe too: an id the saved order never mentions keeps its declared position. - A saved width is clamped to the column's current
minSizeandmaxSize. - A saved page size is dropped when
paginationis off, and the page index with it. - A saved page past the end of the current data falls back to the last page that exists, rather than showing an empty list under a page number that no longer means anything.
Use getRowId if you want a saved selection to survive. Without it, row ids are positions, and positions move.
Pagination
Off by default. Pass pagination or pagination={{ pageSize: 20 }} for a footer under the list.
The footer hides when the list is empty and nothing is filtering it, because there are no pages. alwaysVisible: true keeps it anyway: a table whose footer carried the row count wants that strip to stay put when the data is empty, so the layout does not jump.
<DataList data={projects} columns={columns} pagination={{ pageSize: 20, alwaysVisible: true }} />A search or a filter that empties the list already keeps the footer, with or without the flag: that is where the result count lives.
Empty and error states
The built-in empty state has three voices: a genuinely empty list, a search that matched nothing, and a filter that is hiding everything. empty overrides the heading and the explanation without replacing the whole slot:
<DataList
data={projects}
columns={columns}
empty={{ title: 'No projects', description: 'Create one to get started.' }}
/>errorRetry adds a Retry button to the default error state. renderEmpty and renderError still replace the slot outright, and a custom components.Empty / components.Error still receives title, description and onRetry.
<DataList data={projects} columns={columns} error={loadError} errorRetry={() => refetch()} />Props
| Prop | Type | Notes |
| --- | --- | --- |
| data | T[] | Required. Never mutated. |
| columns | DataListColumn<T>[] | Required in both views. |
| getRowId | (item, index) => string | Strongly recommended when rows can reorder. |
| searchable | boolean \| { placeholder?, debounce? } | Debounced global filter, 200ms default. |
| filterable | boolean \| { fields?, placeholder? } | The filter builder. |
| columnsMenu | boolean | Hide / reorder / pin. Table view only. Own row when filterable is off. |
| exportable | boolean \| { filename?, label? } | CSV of the filtered set. |
| pagination | boolean \| { pageSize?, pageSizeOptions?, alwaysVisible? } | Off by default. Options default to 10 / 20 / 50 / 100 / 200 / 500. |
| selectable | boolean \| 'single' \| 'multiple' | Auto checkbox column in table view. |
| expandable | boolean | Pair with renderExpanded. |
| views | boolean | Table / Cards switch. Needs renderItem. |
| defaultView | 'table' \| 'cards' | Defaults to table. |
| cardColumns | 1 \| 2 \| 3 \| 4 | Cards per row, default 3. |
| autoCardsBelow | number | Cards below this viewport width (px). Needs renderItem. |
| virtual | boolean \| { estimateSize?, overscan?, height? } | Works in both views. |
| infinite | { hasMore, onLoadMore, loader?, rootMargin? } | Use instead of pagination. |
| reorderable | boolean | Pair with onReorder. |
| manual | { rowCount, onQueryChange? } | Server-side sort / filter / paginate. |
| defaultSorting | SortingState | Initial sort. Overridden by initialState.sorting. |
| initialState | Partial<DataListComponentState> | What to restore. Read once on mount. |
| onStateChange | (state: DataListComponentState) => void | Fires on change, never on mount. |
| persistState | { key, storage? } | Built-in persistence. Ignored if initialState or onStateChange is set. |
| loading / error | boolean / unknown | Drive the built-in states. |
| empty | { title?, description? } | Copy for the default empty state. |
| errorRetry | () => void | Adds a Retry button to the default error state. |
| resizable | boolean | List-wide resize switch. Default true. Column enableResizing wins. |
| pinnable | boolean | List-wide pin switch. Default true. Column enablePinning wins. |
| renderItem | (ctx) => ReactNode | Enables card view. |
| renderExpanded | (ctx) => ReactNode | Non-virtual layouts only. |
| renderEmpty / renderLoading / renderError | () => ReactNode | Full overrides. |
| components | { SearchInput?, Pagination?, Empty?, Loading?, Error? } | Swap a part, keep the rest. |
| className / classNames | string / per-slot record | Slots below. |
| rowClassName | string \| (item, index) => string | Conditional row styling. |
| toolbar / footer | ReactNode | Your own controls. |
| onRowClick | (item, row) => void | Cards: sibling role="button" overlay. Table: tabindex on the row. |
| onSelectionChange | (items: T[]) => void | |
| onSortingChange | (sorting) => void | |
| onReorder | (items, from, to) => void | |
| aria-label | string | |
classNames slots: root, toolbar, search, filters, table, thead, headerRow, headerCell, tbody, row, cell, list, item, scroller, pagination, empty, loading, error, footer, dragHandle, sentinel.
renderItem receives { item, row, index, selected, toggleSelected, expanded, toggleExpanded, dragging, dragHandleProps }.
Column options
| Field | Notes |
| --- | --- |
| accessorKey | Supports dot paths: 'user.name'. |
| accessorFn | (row, index) => unknown, for computed values. |
| id | Required when there is no accessorKey. |
| header / cell / footer | String or render function. |
| size / minSize / maxSize | Defaults 150 / 64 / 720. |
| sortFn | 'alphanumeric', 'basic', 'datetime', 'text', or your own. |
| filterFn | 'includesString', 'equals', 'inNumberRange', 'arrIncludes', or your own. |
| enableSorting / enableHiding / enableResizing / enableOrdering / enablePinning / enableColumnFilter / enableGlobalFilter | All on by default. |
| enableExport | In the CSV. Default true with an accessor, false without. |
| meta | Anything you want to read back in a renderer. |
The default sort is alphanumeric with natural number handling. null, undefined and NaN sink to the bottom in both directions instead of jumping to the top when the header is toggled, and dates sort as dates rather than as the text of their weekday.
Server-side data
In manual mode the component stops filtering, sorting and paginating locally and just reports what the reader asked for.
<DataList
data={page.rows}
columns={columns}
searchable
pagination={{ pageSize: 25 }}
manual={{
rowCount: page.total,
onQueryChange: ({ pageIndex, pageSize, sorting, globalFilter, columnFilters }) => {
void refetch({ pageIndex, pageSize, sorting, search: globalFilter, filters: columnFilters })
},
}}
/>Infinite scroll
<DataList
data={items}
columns={columns}
virtual={{ height: 600 }}
infinite={{ hasMore, onLoadMore: fetchNextPage }}
/>An IntersectionObserver watches a sentinel and finds its own scroll container, so it works inside the virtualized scroller and on the page scroller without configuration. It will not re-fire while a load is in flight.
Drag to reorder
<DataList
data={items}
columns={columns}
reorderable
onReorder={(next) => setItems(next)}
/>onReorder hands back a new array; indexes are mapped back to positions in the original data, not the filtered view. Handles are keyboard-reachable with arrow up and arrow down.
Reordering a sorted or filtered list is ambiguous by nature. Turn sorting off on the columns when a list is reader-ordered.
Headless usage
If none of the layouts fit, use the hook and write your own markup.
import { useDataList } from '@zaigo/data-list'
const { table, rows, searchTerm, setSearchTerm, columnFilters, setColumnFilters, state, isEmpty } =
useDataList({
data: projects,
columns,
searchable: true,
pagination: { pageSize: 20 },
initialState: saved,
onStateChange: setSaved,
})state is the same object onStateChange reports, memoised, so you can put it straight in an effect's dependency list. On the hook it is a DataListState: the ten slices the hook owns, without view and cardColumns, which belong to the component.
table is the same instance the component uses: getHeaderGroups(), getRowModel(), getAllLeafColumns(), per-column getSize(), toggleVisibility(), togglePinned(), moveColumn() and the rest.
The engine below it is exported too, so a layout this package does not have can still be built on the same pieces:
import { computeRowModel, resolveColumns, sortFns, filterFns } from '@zaigo/data-list'
import { useTableCore, useVirtualizer } from '@zaigo/data-list'TableView, ListView, ColumnsMenu, ExportButton, DefaultPagination and the rest of the parts are exported individually as well.
What is inside
| Concern | How | Source |
| --- | --- | --- |
| Column resolution, filtering, sorting, pagination, selection, expanding | Row-model engine, no React import | src/core/engine.ts |
| React binding for the engine, plus sizing, ordering and pinning | useTableCore | src/core/use-table-core.ts |
| Virtualization | Prefix-sum offsets, binary search, measured heights | src/core/use-virtualizer.ts |
| CSV | RFC 4180 quoting, BOM, formula guard | src/core/csv.ts |
| Class merging and Tailwind conflict resolution | cn | src/core/cn.ts |
| Drag to reorder | Pointer events | src/use-reorder.ts |
| Infinite scroll | IntersectionObserver | src/use-infinite-scroll.ts |
| State persistence | localStorage / sessionStorage / URL / adapter | src/persist-state.ts |
| Auto card breakpoint | matchMedia, SSR-safe | src/use-narrow-viewport.ts |
Everything above is original code written for this package. No third-party source is vendored or bundled, which is why there is no third-party notice file: there is nothing to attribute.
Grouping, aggregation and faceted counts across the full dataset are deliberately absent. A project that needs those needs a general-purpose table library, not this one.
Development
npm run typecheck
npm run build
npm test
npm run verify
npm run demonpm test runs eleven suites. Some render the built bundle through react-dom/server and assert on the markup; test/interaction.mjs, test/state.mjs and test/v02.mjs mount it in jsdom and drive it with real clicks and keystrokes. jsdom rather than a lighter DOM on purpose, and test/dom-env.mjs is imported before React on purpose. Both reasons are written down in that file.
npm run verify packs the tarball and installs it into fresh consumer projects: Next.js on the App Router and the Pages Router, Vite, a Vite app with no Tailwind, plain Node through both require and import, and React 18 and 19 side by side. Nothing is stubbed and nothing is linked. It is slow and it needs the network, but it is the only thing that catches a broken exports map or a lost "use client" directive. Run it when you touch package.json or tsup.config.ts.
npm run lint:package runs publint and Are the Types Wrong over the packed tarball. Both are part of prepublishOnly.
npm run build is bundled with tsup. Treeshaking is deliberately disabled because rollup strips the "use client" directive the App Router needs.
Releases are automatic: the version in package.json is the switch and merging to main is the trigger. Actions tab → Version → Run workflow bumps it and opens the pull request. See CONTRIBUTING.md.
npm run demo serves a showcase page with 1000 mock rows, every option wired to a control, and the corresponding JSX generated live beside it.
License
MIT
