React table filtering — chips & URL-synced
▶ Try it live: open a Mantine starter in StackBlitz — this page’s feature is already wired in src/App.tsx (declarative filter widgets on four columns); edit it in the browser, no install. Other UI kits →
Declare a filter once and AdaptTable derives everything from it: the
kit-native widget, the f_<key> URL param, the removable chip, and (on
frontend data) the row predicate — no wiring. Nested AND/OR groups are
the advanced filter tree.
Example
Section titled “Example”// Needs your kit's provider once at the root (e.g. <MantineProvider>).import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyledimport { filters } from "@adapttable/mantine/filters";
interface Person { id: string; name: string; department: { name: string }; status: string; salary: number; hiredAt: string; // ISO date}
const data: Person[] = [ { id: "1", name: "Amira Haddad", department: { name: "Engineering" }, status: "active", salary: 98000, hiredAt: "2021-03-15", }, { id: "2", name: "Jonas Weber", department: { name: "Design" }, status: "onleave", salary: 76000, hiredAt: "2019-11-02", }, { id: "3", name: "Priya Nair", department: { name: "Engineering" }, status: "active", salary: 112000, hiredAt: "2023-06-20", }, { id: "4", name: "Sam Ortiz", department: { name: "Sales" }, status: "left", salary: 64000, hiredAt: "2018-01-09", }, { id: "5", name: "Lena Park", department: { name: "Design" }, status: "active", salary: 89000, hiredAt: "2022-09-12", },];
export function PeopleTable() { return ( <DataTable data={data} rowKey={(r) => r.id} columns={[ { key: "name", filter: "text", sortable: true }, // "auto" derives the choices from the data (frontend tier). { key: "department.name", header: "Department", filter: { type: "select", options: "auto" }, }, // Async options — usually `async () => (await fetch("/api/statuses")).json()`. { key: "status", filter: { type: "multiSelect", options: async () => [ { value: "active", label: "Active" }, { value: "onleave", label: "On leave" }, { value: "left", label: "Left" }, ], }, }, { key: "salary", filter: "numberRange", sortable: true }, { key: "hiredAt", filter: "dateRange" }, ]} features={[ filters([ { key: "tenure", type: "numberRange", label: "Tenure (years)", getValue: (r) => (Date.now() - new Date(r.hiredAt).getTime()) / 31_557_600_000, }, ]), ]} filtersMode="popover" // the default; "drawer" or "header" — one mode, never stacked /> );}Import filters from @adapttable/<kit>/filters — the import is the switch.
Column filter declarations still need that feature: pass filters([]) when
every filter lives on a column, or pass standalone defs as above. See
feature composition.
How it works
Section titled “How it works”- Two declaration sites, merged column-first: the column
filtershorthand (a bare type like"dateRange", or a definition withoutkey/label— both inherited from the column) and standalone entries passed tofilters([…])for filters with no column. On a key collision the standalone definition wins and a development warning points at the duplicate. - Seven built-in types (
FILTER_TYPES):text,select(equals),multiSelect(wrapping multi-value chips),checklist(Excel-style distinct values with search, select-all and counts — fromsource.facetswhen present, otherwisesource.allFilteredRows; a server page that omits both does not offer the widget),boolean(any / true / false — never a checkbox),dateRange,numberRange. - Widgets are operator-first. Text offers equals / not equals / contains /
not contains / starts with / ends with / empty / not empty. Numbers offer
=≠>≥<≤between / in / not in. Dates offer before / after / on / on-or-after / on-or-before / between / empty. The operator token is stored asf_<key>Op(readable, stable across releases) beside the value keys (f_name,f_salaryMin/f_salaryMax,f_hiredAtFrom/f_hiredAtTo). Links written beforeOpexisted still work: text defaults to contains, and a Min/Max pair still infers at-least / at-most / between. select/multiSelectoptions come from a static{ value, label }[],"auto"(distinct values derived from the frontend dataset, sorted, capped atAUTO_OPTIONS_LIMIT= 50, the same number asFILTER_AI_OPTIONS_LIMIT), or an async loader — one shared fetch serves both the form and the chip labels, and active chips re-label from raw values once it resolves.- A definition’s
keydoubles as the row’s dot path for the client-side predicate ("department.name"reaches nested values);getValueoverrides it for computed values. - Active filters render as removable chips with a clear-all that resets every
filter (and the page) while search and sort survive;
onClearFiltersreplaces the built-in handler.
Options
Section titled “Options”FilterDef (entries of filters, and the column filter object minus
key/label):
| Prop | Type | Default | Description |
|---|---|---|---|
key |
string |
— | State key and f_<key> URL param. Doubles as the row’s dot path unless getValue overrides it. |
type |
string |
— | Built-in FilterType or a custom type registered on filterTypes. |
label |
string |
humanized key |
Widget and chip label (hiredAt → “Hired At”). |
options |
FilterOption[] | "auto" | () => Promise<FilterOption[]> |
— | Choices for select / multiSelect. |
getValue |
(row) => unknown |
reads key as a path |
Row-value extractor for the client-side predicate. |
placeholder |
string |
— | Placeholder for text-like inputs. |
ai |
false | FilterAiOptions |
visible, options ≤ 50 | Assistant catalog. false hides the filter. { options: false } keeps it and omits values. A number sends values only when the static list is that long or shorter (FILTER_AI_OPTIONS_LIMIT). "auto" and async loaders are never fetched into the prompt. |
| Factory / prop | Type | Default | Description |
|---|---|---|---|
filters([…]) |
FilterDef[] | ReactNode |
— | Declarative array → the adapter builds the form; JSX → you draw it (escape hatch). Use filters([]) when every filter is on a column. |
filtersMode |
"popover" | "drawer" | "header" |
"popover" |
One container. Popover: anchored card, no backdrop. Drawer: panel + backdrop. Header: compact per-column row; hides the Filters button. |
onClearFilters |
() => void |
built-in clear | Clear handler used by the drawer and the chip strip. |
filterLabels |
Record<string, ChipLabelResolver> |
derived | Per-key chip label resolvers. Derived automatically by declarative filters; needed only for JSX filters (or to override a derived label). |
extraChips |
ActiveFilterChip[] |
— | Extra chips driven by non-URL state, merged with the derived chips. |
activeFilterCount |
number |
chip count | Overrides the Filters-button badge. |
closeHeaderFilterOnSelect |
boolean |
false |
Close a header-filter overlay after a finished single-control write (select/boolean, or a valueless operator). Off by default. |
filterTypes([…]) |
FilterTypeSpec[] |
built-ins | Extra or replacement filter types merged onto defaultFilterRegistry. Same type replaces. Compose via features. |
headerFilters() |
factory | — | Compose when filtersMode="header" (or pass headerFilters on a test harness). Desktop only. Never stacked with the popover or drawer. |
Headless filter primitives
Section titled “Headless filter primitives”The pieces behind the auto-built forms are exported for custom filter UIs:
-
Count filters (the numeric operator + value pair, committed stable in Versioning & stability):
COUNT_OPERATORSis the operator list andCOUNT_OPERATOR_SYMBOLthe display symbol perCountOperator;CountFilterStateis the widget state;countFilterExtra/countFilterStateFromExtraconvert state to and from the filter bag;isCountFilterComplete,clearCountFilterExtra,sanitizeCountFilterParamsandcountFilterChipLabelhandle validation, reset, outgoing params, and the chip text. -
Operators:
TEXT_OPS/NUMBER_OPS/DATE_OPSare the stable URL tokens (FilterOp,TextOp,NumberOp,DateOp).filterOpKey/FILTER_OP_SUFFIXname thef_<key>Opslot;parseTextOp/parseNumberOp/parseDateOp/readFilterOpread it;isValuelessFilterOp/isListFilterOp/isBetweenFilterOpclassify operand shape.TEXT_OP_LABEL_KEYS/NUMBER_OP_LABEL_KEYS/DATE_OP_LABEL_KEYSmap each token to aTableLabelskey.formatFilterChip/filterOpLabel/isEmptyRowValue/parseListOperand/parseNumberList/isFilterOpKeyare the helpers.useTextFilterWidgetreturns aTextFieldWidget. Relative windows:RELATIVE_NAMED/RELATIVE_PRESETS/RELATIVE_PRESET_LABEL_KEYS/RelativeDateToken/RelativeDateRange/RelativePreset/parseRelativeToken/isRelativeDateToken/countedRelativeToken/splitRelativeToken/joinRelativeToken/relativeTokenLabel/resolveRelativeRange. AND/OR groups:FILTER_TREE_PARAM/FILTER_TREE_VERSION/parseFilterTree/serializeFilterTree/isActiveFilterTree/evaluateFilterTree/conditionToExtraover aQueryFilterGroupofQueryConditions (isFilterGroupnarrows a child). Each adapter’sFilterTreeBuildersits at the top of the Filters form whensource.setFilterTreeis set;toolbarShowsFilterskeeps the toolbar button in header mode for that tree. The engine storesft=1.{…}and evaluates the tree on the frontend tier (ANDed with the flat extra bag).useTableDatawiresevaluateFilterTreeitself; a host that callsuseFrontendDatadirectly passesfilterTreeFnover the same defs asfilterFn. A server that declaressupports.filterTreereceives the same tree onquery.filterTree. Tree leaves become chips viauseFilterTreeChips; Clear all dropsft. See filter-tree. -
Facet counts:
computeFilterFacets/rowsExcludingFilter/FacetMap/FacetCountscount what selecting a value would keep — the filtered set with that facet’s own filter removed. FrontenduseTableDatacomputes them fromallSearchedRows(after search, before extras). A server that declaressupports.facetsreceivesquery.facets(checklist keys) and returns the same map on the page;useQuerySource/useServerDatasurface it assource.facets.useChecklistFilterprefers that map overallFilteredRows. -
Type registry:
FilterTypeSpecis one type — widget kind, operators, predicate, chips, tree projection, optionalrender. Built-ins (builtInFilterSpecs/defaultFilterRegistry) are the first consumers;filterTypeson the table merges extras viaresolveFilterRegistry/createFilterRegistry. A custom type registers throughTableFeatureHost.registerFilterType/extendFilterTypeinfeature.setup(host), orfeatures={[filterTypes(specs)]}.filterWidgetKind/filterTypeOps/filterTypeDefaultOp/filterTypeSpec/renderRegisteredFilterlook a spec up. A custom type withwidget: "text"draws the text widget;extend("text", { ops })adds operators without forking.emptyFilterRegistryseeds a registry from scratch.FilterTypeRegistry/FilterWidgetKind/FilterWidgetRenderPropsare the types. -
Header filter row: compose
headerFilters()and setfiltersMode="header"(see feature composition) mounts each adapter’sFilterHeaderRow/FilterHeaderControloverFilterHeaderChrome/FilterHeaderControlChrome. HelpersfilterDefForColumn/headerFilterStickTopstay on core. The row sits under the leaf header and hides the toolbar Filters button (resolveFilterMode/FilterChromeMode). Pads and column spacers match the header so sticky, pin offsets, and column windowing stay aligned. A def whose bag key differs from the column key setscolumn(key: "name"undercolumn: "person"). Ant Design keeps the control inside the header cell sofixedcolumns stay on antd’s own header. Compact range inputs default the operator togte(no picker in the header); checklist / multiSelect open a closed menu of checkboxes, not a native<select multiple>. The funnel overlay stays open while you fill a multi-input field; nested kit dropdowns are not treated as outside clicks. PasscloseHeaderFilterOnSelectto dismiss after a finished single-control write (useHeaderFilterOverlay/bindHeaderFilterDismiss/headerFilterFieldIsComplete/usePointerDismiss/HeaderFilterSessionProps/HeaderFilterOpenProvider/HeaderFilterOpenContext/HeaderFilterOpenHost). -
Range widgets:
useRangeFilterWidgetis the kit-agnostic logic behindnumberRange/dateRangefields — it returns aRangeWidgetStatewhoseRangeFieldWidgetentries carry the visible bounds, the activeRangeOp, and aRangeOpArity(none/one/two/list);RANGE_SUFFIXESnames the persistedMin/Maxkey pair,RANGE_OPSis the historical four-operator set (eq/gte/lte/between), andRANGE_OP_LABEL_KEYS/RangeOpLabelKeysmap each operator to itsTableLabelskey.writeRangeFilterpersists the pair plusf_<key>Op. -
Definitions and state:
filterStateKeyslists the state keys a definition reads and writes;scalarFilterTextrenders a scalar filter value as input text;listFilterValuesnormalizes a multi-select value list;isDeclarativeFiltersnarrows thefiltersprop to its array form;FilterFormSourceis the minimal source shape a filter form needs;ResolvedFilterOptionsis the loaded state of afilter’s options (includingoptions: "auto");FilterRuntimeis everything the engine derives from the resolved definitions (defs, chip labels, URL keys, predicate). -
Search:
defaultSearchTextis the default searchable-text projector — it flattens a row’s own values into the string the search box matches against. Replace it per source withgetSearchText:const source = useFrontendData({data: people,columns,// search the full name and the city, and nothing elsegetSearchText: (row) => `${row.firstName} ${row.lastName} ${row.city}`,});It is one projector for the whole row, not a per-column setting: search asks “does this row match?”, so the row is what gets projected. Include a computed value here to make it searchable, or leave a field out to exclude it — an id column nobody searches by, say.
"auto"needs the full dataset, so it only works on the frontend tier (datawithoutonQueryChange). On the server/source tiers it dev-warns and resolves to no options — pass an array or an async loader instead.- The
dateRangeupper bound is inclusive end-of-day: “On or before 2026-01-31” keeps that day’s rows. - Relative date filters (
DATE_OPStokenrelative) store a token (today,yesterday,tomorrow,thisWeek,thisMonth,previousMonth,last:N,next:N) in${key}Fromplusf_<key>Op=relative. The URL and Saved Views never hold a resolved calendar day — a shared “last 7 days” link stays the last 7 days tomorrow.resolveRelativeRangeis the only resolver; the frontend predicate and a server query both call it so they agree. Weeks are ISO (Monday–Sunday, local time). Equalwrites the same value to both range keys; clearing a field clears its key, so half-filled widgets never leak stale bounds.- Async loaders run once (the promise is cached); until they resolve, chips label with the raw value. A failed load dev-warns and yields no options.
- Passing JSX to
filters(…)switches off every derivation — your controls update table state themselves (live by default), and you supplyfilterLabels/extraChips/activeFilterCountfor the chips and badge. - Changing any filter resets the page to 1.
multiSelectURL values are comma-separated with each entry encoded, so values containing commas round-trip safely. WithurlKey="left", params becomeleft.f_status, ….
See it live in the demo.