Components

Data Table

A Shopify admin-style index table. One search bar holds views, filter chips and GitHub-style qualifiers, alongside saved views, sortable and filterable headers, and cross-page selection with bulk actions.

Loading...

Installation

npx shadcn@latest add @fujin/data-table

This adds table, button, badge, checkbox, dropdown-menu, input, popover, skeleton and tooltip, plus @tanstack/react-table and nuqs.

Usage

useDataTable holds the state and <DataTable> renders it.

import { DataTable } from "@/components/data-table/data-table"
import type { DataTableColumnDef, FilterDef } from "@/lib/data-table/types"
import { useDataTable } from "@/lib/data-table/use-data-table"
const columns: DataTableColumnDef<Product>[] = [
  {
    id: "title",
    accessorKey: "title",
    header: "Product",
    meta: { lockVisibility: true },
  },
  { id: "status", accessorKey: "status", header: "Status" },
  {
    id: "price",
    accessorKey: "price",
    header: "Price",
    meta: { align: "end" },
  },
]
 
const filters: FilterDef<Product>[] = [
  {
    key: "status",
    label: "Status",
    type: "enum",
    pinned: true,
    negatable: true,
    options: [
      { value: "active", label: "Active" },
      { value: "draft", label: "Draft" },
    ],
  },
  { key: "price", label: "Price", type: "number-range", prefix: "$" },
]
 
export function Products({ products }: { products: Product[] }) {
  const table = useDataTable({
    data: products,
    columns,
    filters,
    getRowId: (product) => product.id,
    tableId: "products",
  })
  return (
    <DataTable
      table={table}
      resourceName={{ singular: "product", plural: "products" }}
    />
  )
}

Define columns, filters and views outside the component, or memoize them.

Filters

A filter is a FilterDef. It doesn't have to be a column: Shopify filters products on "Tagged with" without showing tags. key is the URL parameter, the typed-qualifier name and the key in list.filters.

typePickerValue
enumCheckboxes with search; is / is not{ op: "in" | "not_in", values }
asyncSame, options loaded as you type{ op: "in" | "not_in", values }
text"contains" input{ op: "contains", value }
booleanYes / No{ op: "eq", value }
number-rangeMin and max{ op: "between", min, max }
date-rangeToday, Last 7/30/90 days, Last 12 months, custom{ op: "between", min, max }

Options for every filter:

  • pinned lists it first.
  • negatable adds "is not".
  • multiple: false allows one value only.
  • aliases adds other qualifier spellings.
  • getValue(row) tells client mode what to test. It defaults to the column whose id (or meta.filterKey) matches key.

For values you only know from the data, uniqueOptions(rows, (row) => row.vendor) builds enum options with counts.

The search and filter bar

It works like the search bar in the Shopify admin's Products and Orders pages:

  • Focus lists the filters. Pick one and its chip opens with the picker.
  • Type to get matching filters and values. "dr" offers Status is Draft, and a view whose name matches the text is offered as well.
  • Chips read "Status is Active, Draft". Click one to edit it; Delete or Backspace removes it. ← and → move between chips and back into the box.
  • Free text searches after a short pause, or on Enter. In client mode every word must appear somewhere in the row; "quoted phrases" stay together.
  • ⓧ returns to the selected view's own query.

GitHub-style qualifiers work too. They turn into chips when you type a space or press Enter:

status:active,draft        comma = any of
-vendor:acme               is not (negatable filters)
title:"halo ring"          quotes for spaces
price:10..20  price:>=10   ranges
created:2026-01-01..2026-03-31
giftCard:yes

Values match an option's value or label, or a unique prefix of one (vendor:acme finds "Acme Co"). After status:, the list offers that filter's values. A qualifier the table doesn't recognise stays in the box with a warning and never blocks the rest.

Views

const views: DataTableView[] = [
  { id: "all", label: "All", query: {} },
  { id: "active", label: "Active", query: { filters: { status: { op: "in", values: ["active"] } } } },
]
 
<DataTable table={table} views={views} />
  • The view menu sits at the start of the bar. A dot marks unsaved changes.
  • Save as stores the current query as a new view. Names are unique and at most 40 characters.
  • On a saved view, Save offers "Update view" or "Save as new view".
  • Saved views can be renamed, duplicated and deleted. Built-in views can't.

With tableId set, saved views stay in this browser. Pass savedViews and onSavedViewsChange to keep them on your server and share them with the team.

Server mode and URL state

useUrlListState keeps search, sort, filters and page in the URL as flat, readable parameters. The same parameters are what your API receives:

?q=ring&status=active,draft&vendor_not=Acme%20Co&price_gte=10&price_lte=50&created_gte=2026-01-01&sort=-price&page=2
import { useUrlListState } from "@/lib/data-table/use-list-state"
 
const list = useUrlListState({ filters })
const { data, isFetching } = useQuery({
  queryKey: ["products", list.params, list.pagination],
  queryFn: () =>
    api.products({ ...list.params, page: list.pagination.pageIndex + 1 }),
})
 
const table = useDataTable({
  mode: "server",
  data: data?.rows ?? [],
  rowCount: data?.total ?? 0,
  getRowId: (product) => product.id,
  columns,
  filters,
  list,
  pagination: list.pagination,
  onPaginationChange: list.onPaginationChange,
})
 
<DataTable table={table} loading={!data} fetching={isFetching} />
  • Mount a <NuqsAdapter> once near the app root: nuqs/adapters/next/app or nuqs/adapters/react.
  • In Next.js, pages that fetch in a Server Component pass useUrlListState({ filters, shallow: false }).
  • In server mode the types require getRowId, so "Select all" never confuses rows from different pages, and rowCount.
  • Server-mode columns are sortable only when they set meta.sortField, because most APIs sort on a few fields only.
Filter valueParameters
any ofkey=a,b (commas in values arrive as %2C)
none ofkey_not=a,b
containskey=text
yes / nokey=true / key=false
rangekey_gte=min&key_lte=max (numbers, or YYYY-MM-DD)

Selection and bulk actions

<DataTable
  table={table}
  bulkActions={[
    {
      id: "activate",
      label: "Set as active",
      promoted: true,
      onAction: (selection) => activate(selection),
    },
    {
      id: "archive",
      label: "Archive",
      supportsAllMatching: true,
      onAction: (selection) => archive(selection),
    },
    {
      id: "delete",
      label: "Delete",
      variant: "destructive",
      onAction: (selection, rows) => remove(rows),
    },
  ]}
  rowActions={[{ id: "edit", label: "Edit", onAction: (row) => open(row) }]}
  onRowClick={(row) => open(row)}
/>
  • Selecting rows swaps the header for the bulk-action bar. Shift-click selects a range.
  • When every row on the page is selected, Select all N covers every matching row across pages. Rows unchecked after that are excluded, and Undo goes back.
  • Promoted actions are buttons; the rest go under More actions.
  • onAction receives a SelectionDescriptor: { type: "include", ids }, or { type: "exclude", ids, total } after "Select all". It also receives the selected rows that are loaded. Actions without supportsAllMatching are disabled after "Select all".
  • A row click opens the record. While rows are selected, it toggles the row instead, as in Shopify.
  • Changing the search or filters clears the selection and returns to page 1. Sorting keeps the selection.

Columns and sorting

  • Click a header to sort: ascending, then descending, then off.
  • Each header menu offers sort, Filter (the same picker and state as the chips) and Hide column.
  • The ⇅ panel holds "Sort by" and "Columns", where columns are shown, hidden and reordered with buttons. The leftmost column stays first.
  • With tableId, column choices persist in this browser.
  • The checkbox and first columns stick while a wide table scrolls sideways.
  • stickyOffset keeps the toolbar under a fixed site header. maxHeight scrolls rows under a sticky header row.

CSV export

import {
  downloadCsv,
  exportableRows,
  rowsToCsv,
} from "@/lib/data-table/csv-export"
 
;<DataTable
  table={table}
  toolbarActions={
    <Button
      variant="outline"
      size="sm"
      onClick={() =>
        downloadCsv(
          rowsToCsv(table.core, exportableRows(table)),
          "products.csv"
        )
      }
    >
      Export
    </Button>
  }
/>

exportableRows returns the selection, or every matching row across pages, in display order. Values that spreadsheets would run as formulas are neutralized. In server mode, export on the server from list.params and the selection descriptor.

Accessibility

  • The search box is an ARIA combobox. Arrow keys move through suggestions, Enter picks one, and Escape closes the list, then clears the text.
  • Chips, header menus, pickers and the sort and columns panel all work from the keyboard. Columns reorder with buttons, never by dragging only.
  • A hidden status region announces the result count after a search or filter. The bulk-action bar announces the selection count.
  • Headers set aria-sort, checkboxes have 24px hit areas, and no single-key shortcuts are registered.

API Reference

Prop

Type

On this page