npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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

react >= 18 is the only peer dependency. There is nothing else, and you can check that yourself:

npm ls @zaigo/data-list --all

Master 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 · csv

Your 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 cell exports and filters on its underlying value, because a React node cannot be turned back into text. Where the value and what you drew differ, options or getOptionLabel is how you say so.
  • Under manual the loaded page is a sample, not the value list, so nothing is inferred: every field is a text box unless type says otherwise, and a select needs explicit options.

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 columns no 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 minSize and maxSize.
  • A saved page size is dropped when pagination is 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 demo

npm 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