Tablecraft
API Reference

useFacetedFilters

Per-column facet options with row counts, selection handlers, and numeric range bounds.

Builds the data behind a faceted filter UI — the "Status: Active (24), Archived (7)" checkbox list — from the table itself, so the option list never drifts from the data.

Works with any table from useTable; the required row models are wired in automatically.

Signature

function useFacetedFilters<TData extends RowData>(
  table: TablecraftTable<TData>
): FacetedFiltersReturn

Return

PropertyTypeDescription
getFacet(columnId: string) => ColumnFacetCategorical facet: distinct values with counts, plus selection handlers
getRangeFacet(columnId: string) => RangeFacetNumeric facet: true min/max bounds, plus a range setter

ColumnFacet

PropertyTypeDescription
options{ value, count, selected }[]Distinct values, sorted count-descending
selectedunknown[]Currently selected values
toggle(value: unknown) => voidAdds or removes a value
isSelected(value: unknown) => booleanWhether a value is selected
clear() => voidRemoves the column's filter

RangeFacet

PropertyTypeDescription
min / maxnumber | undefinedColumn bounds, ignoring this column's own filter
value[number, number] | undefinedCurrently applied range
setRange(range: [number, number] | undefined) => voidApplies a range; undefined clears
clear() => voidRemoves the column's filter

Required column setup

Faceted columns must declare a filter function matching the value shape the hook writes. Without it, filtering silently returns wrong rows — the hook warns in development.

import { createColumns, facetedFilterFn } from '@marvinackerman/tablecraft'

const columns = createColumns<Product>([
  { accessorKey: 'status', header: 'Status', filterFn: facetedFilterFn },
  { accessorKey: 'price',  header: 'Price',  filterFn: 'inNumberRange' },
])

facetedFilterFn is a root-entry export — the filter function categorical facet columns must declare. It checks the cell value for membership in the array of selected values that toggle writes; TanStack's built-in arrIncludesSome looks similar but is built for array-valued cells, not for faceting a scalar column, so it is not a substitute.

Usage

import { useTable, useFacetedFilters, facetedFilterFn } from '@marvinackerman/tablecraft'

const { table } = useTable({ data, columns })
const { getFacet, getRangeFacet } = useFacetedFilters(table)

const status = getFacet('status')

return (
  <fieldset>
    <legend>Status</legend>
    {status.options.map(({ value, count, selected }) => (
      <label key={String(value)}>
        <input
          type="checkbox"
          checked={selected}
          onChange={() => status.toggle(value)}
        />
        {String(value)} ({count})
      </label>
    ))}
    {status.selected.length > 0 && (
      <button onClick={status.clear}>Clear</button>
    )}
  </fieldset>
)

Range facets bound a slider with the column's real values:

const price = getRangeFacet('price')

<input
  type="range"
  min={price.min ?? 0}
  max={price.max ?? 100}
  onChange={(e) => price.setRange([price.min ?? 0, Number(e.target.value)])}
/>

Counts ignore the column's own filter

This is the behaviour that makes multi-select usable. When "Active" is checked, the count beside "Archived" still reports how many rows you would gain by also checking it — rather than 0, which would make every other option unclickable.

A useful side effect: clicking a value never reorders that facet's own option list, because its counts cannot change. Only filters on other columns reorder it.

Limitations

Server-side tables. useServerTable and useQueryTable hold one page, not the dataset, so counts computed from them would describe the current page — plausible and wrong. On a manualPagination table the facets are empty and the hook warns. Compute facets server-side and render them yourself if you need them there.

Array-valued columns. Counts come from row.getUniqueValues(), which by default returns the cell value as a single entry — so a tags: string[] column yields one option per distinct array rather than one per tag. Set a getUniqueValues option on the column def to split them:

{ accessorKey: 'tags', header: 'Tags', filterFn: 'arrIncludesSome',
  getUniqueValues: (row) => row.tags }

(arrIncludesSome is correct here — an array-valued cell is exactly what it was designed for. It is only wrong on the scalar columns faceting normally targets.)

Empty values are dropped. null, undefined and '' never appear as options — a checkbox labelled "" is not useful. Those rows are still counted in the row model; they are simply not offered as a choice.

Facet values must be JSON-round-trippable primitives. Selections work with URL sync and persistence, but only for values that survive a JSON round trip. Strings, numbers and booleans come back unchanged; a Date does not — URL persistence JSON-encodes the filter array, and JSON.parse hands back the ISO string rather than a Date instance, so facetedFilterFn's equality check will never match it again. Facet a derived string field instead of a Date value if you need URL sync or persistence.

The two accessors share one filter slot per column. columnFilters holds a single value per column, so getFacet and getRangeFacet on the same column are reading and writing the same slot. Each refuses to interpret the other's value: on a column declaring filterFn: 'inNumberRange', getFacet reports nothing selected rather than turning the two range bounds into two checked boxes, and getRangeFacet reports no range on a column using facetedFilterFn. Options and min/max are still listed either way — only the selection is withheld.

One gap remains, and it is not solvable without provenance on the filter value itself: a custom filterFn is ambiguous. getRangeFacet treats any custom function as a range column, so a bespoke value-list filter's two numeric selections will be reported as an applied range. Declare filterFn: facetedFilterFn for value lists and 'inNumberRange' for ranges and neither case arises.

Which range filters are recognised. getFacet withholds the selection for columns declaring a range filter that tablecraft ships — 'inNumberRange' and inDateRange — so an applied range is never reported as two checked boxes. The ambiguity described above applies only to a bespoke function of your own: tablecraft cannot tell a custom value-list filter from a custom range one.

getRangeFacet on a date column is only half usable. It reports a closed range correctly, but value is undefined as soon as either end is open, and setRange is typed [number, number] so it cannot express an open bound at all. Drive date ranges through columnFilters.setFilter instead — see Date range filtering.

useTable({ columnFilters: false }) makes facets inert. With column filters disabled, getFilteredRowModel is not available, so toggle writes filter state that never actually filters any rows — silently, with no warning.

On this page