Skip to content

Feat faceted filters - #12

Merged
Marvinkwame merged 16 commits into
mainfrom
feat-faceted-filters
Sep 10, 2026
Merged

Marvinkwame merged 16 commits into
mainfrom
feat-faceted-filters

Conversation

@Marvinkwame

Copy link
Copy Markdown
Owner

Summary

Adds useFacetedFilters, the data layer behind a faceted filter sidebar — the
"Status: Active (24), Archived (7)" checkbox list, and numeric range sliders
bounded by the column's real values.

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

const status = getFacet('status')
// status.options → [{ value: 'Active', count: 24, selected: false }, …]
// status.toggle('Active') / status.isSelected(v) / status.clear()

const price = getRangeFacet('price')
// price.min / price.max bound the slider; price.setRange([10, 40]) applies it

Selections are written straight to columnFilters, so URL sync and persistence
keep working with no extra wiring. useTable now passes TanStack's three
faceted row models unconditionally — they create one memoised closure per column
and compute only on access, so there is nothing to opt into and nothing to
remember. Ships from the root entry; no new dependency.

Why

Filtering is wrapped already, but nothing could answer the question every filter
UI needs: what values are in this column, and how many rows does each have?
Without it a consumer either hardcodes the option list — which then drifts from
the data silently — or rederives it from the raw rows, duplicating work the table
already does and getting the interaction subtly wrong.

The detail that makes it work

Counts come from column.getFacetedUniqueValues(), which is built on the
faceted row model. That model deliberately excludes the column's own
filter:

const filterableIds = [...columnFilters.map(d => d.id).filter(d => d !== columnId), ...]

Reading getFilteredRowModel() instead would collapse every other option in a
facet to zero the moment the user ticked one box, making the rest unclickable.
A useful side effect: because a facet's counts can't be changed by its own
filter, the option list never reorders under the cursor while you click it —
which is what makes count-descending sorting safe.

There are paired tests for this: one asserts a facet's own counts are unchanged
after toggling one of its values, the other asserts they do change when a
different column is filtered. Both would have to be rewritten to make a
regression pass.

facetedFilterFn — a new root export, and why it exists

TanStack ships no built-in meaning "cell value is one of the selected
values", and the obvious candidate is a trap. arrIncludesSome calls
.includes() on the cell value, not the selection:

const arrIncludesSome = (row, columnId, filterValue) =>
  filterValue.some(val => row.getValue(columnId)?.includes(val))

It exists for columns whose cell value is itself an array. On the scalar columns
faceting actually targets, a numeric column throws (.includes is not a function) and a string column silently substring-matches — selecting
Admin also matches Super Admin. Verified against the v8 source.

So faceted columns declare tablecraft's own:

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

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

This was found mid-implementation, after the spec, plan, docs and every test
fixture had already standardised on arrIncludesSome. The existing tests passed
only because the fixture values happened to share no substrings.

Development warnings

Four conditions the hook can detect but not fix, each NODE_ENV-guarded and
fired once per column per reason:

Condition Why it can't be silent
toggle on a column missing facetedFilterFn Filtering returns wrong rows with no error
setRange on a column not configured for ranges Same, in the other direction
Unknown column id A stale facet-bar config would otherwise fail silently
manualPagination table Facets are empty rather than page-scoped

A custom filterFn never warns — it may well handle the value shape, and the
hook can't know. This is deliberately unlike useTableExport's rows: 'selected'
case, which stayed undetectable and was left to documentation.

Limitations, documented rather than worked around

Server-side tables. useServerTable and useQueryTable hold one page, not
the dataset, so counts derived from them would describe the current page —
plausible and wrong. On a manualPagination table the facets are empty and the
hook says why. Left unguarded this would not have failed loudly, which is what
made the guard worth writing.

Facet values must be JSON-round-trippable primitives. src/utils/url.ts
JSON round-trips the filter array, so a Date returns as an ISO string and the
equality check will never match it again.

Array-valued columns yield one option per distinct array, not per tag.
TanStack's getUniqueValues column option is the escape hatch.

useTable({ columnFilters: false }) makes facets inerttoggle writes
filter state that never filters anything.

Testing

Tests   531 passed  (445 baseline + 86 new)
Lint    tsc --noEmit, clean
Build   check-dts: OK, check-entry-deps: OK

check-entry-deps: OK confirms the root entry still reaches only react and
@tanstack/react-table.

Assertions use exact counts and exact option order throughout. Set-based and
toBeGreaterThan assertions are excluded on purpose — that pattern previously
hid a real duplicate-rows bug in the export work by passing against wrong data.

Breaking changes

None. Additive only. useTable gains three row-model options internally; no
public signature changes.

Notes for the reviewer

Three defects were caught during implementation and review that are worth
knowing about, since two of them originated in the plan rather than the code:

  1. arrIncludesSome, above — the whole documented setup was wrong.
  2. table.getColumn(id) console.errors on an unknown id whenever
    NODE_ENV !== 'production'. That would have fired on every render for any
    consumer whose facet config carried a stale column id. Replaced with a
    findColumn helper over getAllLeafColumns().
  3. setRange didn't warn on a value-list column. The read path was hardened
    to refuse reporting a value list as a range, but the write path never caught
    up — so setRange([10, 50]) on such a column wrote a filter matching only
    rows priced exactly 10 or 50, read back undefined, and warned about nothing.
    Both paths now share one isRangeFilterFn predicate.

Two things I'd flag for a second opinion:

  • getFacet has no guard mirroring getRangeFacet's. On an
    'inNumberRange' column holding [20, 40], selected reads [20, 40] and
    those options render checked. Within contract — mismatching the two accessors
    is documented as inert — but only one direction got hardened.
  • Both accessors are new functions every render, unlike useTableA11y's
    useCallback-wrapped prop-getters, so a consumer useEffect keyed on them
    re-fires. [table, isServerTable] would be correct and stable deps; the
    original rationale for skipping memoisation was wrong. Left as-is to avoid
    churn at this stage.

package.json is unchanged and the CHANGELOG entry sits under [Unreleased]
this would be 3.2.0, and 3.1.0 is still unpublished (npm serves 3.0.0, and
there's no v3.1.0 tag).

Marvinkwame and others added 16 commits September 8, 2026 21:58
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
arrIncludesSome calls .includes() on the cell value, so it throws on
numeric columns and substring-matches on string columns (selecting
"Admin" also matched "Super Admin"). facetedFilterFn checks the cell
value for exact membership in the selected-values array getFacet
actually writes, which is what faceted columns need. Corrects the
status/category fixture in useFacetedFilters.test.tsx to declare it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduce isRangeFilterFn as the single source of truth for what counts
as a range filterFn, used by both the read path (isRangeColumn) and the
setRange warning guard. Previously only the read path checked identity
against facetedFilterFn; setRange relied on needsRangeFilterFn alone,
which returns false for any function and so silently accepted
facetedFilterFn as a valid range target.
…ter state

normalizeSelected returned the array filter value by reference, so
facet.selected was the literal array stored in columnFilters and
mutating it would mutate table state. emptyFacet() already guards
against this on options via a factory; normalizeSelected now does the
same by returning [...filterValue].
- facetsEntry.test.ts: assert the root export is the same function
  object as src/utils/facets' facetedFilterFn — isRangeFilterFn depends
  on this identity holding through the package root.
- useFacetedFilters.test.tsx: add a test that builds the table with
  pagination: { pageSize: 2 } and asserts getFacet counts still
  describe the full dataset, not the current page. Every existing
  facet test used pagination: false, so the default paginated path was
  previously unexercised.
…tions

- The 'Required column setup' and Usage snippets now import
  facetedFilterFn instead of assuming it is already in scope, with a
  short paragraph naming it as a root-entry export.
- Limitations: facet values must be JSON-round-trippable primitives
  (a Date comes back from URL persistence as an ISO string and will
  never match facetedFilterFn's equality check again), and
  useTable({ columnFilters: false }) makes facets inert with no
  warning.
@vercel

vercel Bot commented Sep 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
tablecraft Ready Ready Preview Sep 10, 2026 7:20am UTC
tablecraft-g1vu Ready Ready Preview Sep 10, 2026 7:20am UTC

@Marvinkwame
Marvinkwame merged commit 81baea3 into main Sep 10, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant