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>
): FacetedFiltersReturnReturn
| Property | Type | Description |
|---|---|---|
getFacet | (columnId: string) => ColumnFacet | Categorical facet: distinct values with counts, plus selection handlers |
getRangeFacet | (columnId: string) => RangeFacet | Numeric facet: true min/max bounds, plus a range setter |
ColumnFacet
| Property | Type | Description |
|---|---|---|
options | { value, count, selected }[] | Distinct values, sorted count-descending |
selected | unknown[] | Currently selected values |
toggle | (value: unknown) => void | Adds or removes a value |
isSelected | (value: unknown) => boolean | Whether a value is selected |
clear | () => void | Removes the column's filter |
RangeFacet
| Property | Type | Description |
|---|---|---|
min / max | number | undefined | Column bounds, ignoring this column's own filter |
value | [number, number] | undefined | Currently applied range |
setRange | (range: [number, number] | undefined) => void | Applies a range; undefined clears |
clear | () => void | Removes 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.