How it works
The mental model: one config in, two things out, synced to the URL.
Everything in this library follows from one idea: your filter state lives in the URL, and a single hook keeps it in sync with the two things your screen needs: a value to fetch with, and controls to render.
One config in, two things out
You give useFilters a config: a plain object describing your filters. You get
back two outputs, used in different places:
const { params, filters } = useFilters({
status: f.select({ label: 'Status', valueType: 'string', options: statusOptions }),
search: f.text({ label: 'Search' })
});paramsis your data side: a plain object of the committed values plus pagination ({ status, search, page, per_page }), shaped to hand straight to your API and to use as a cache key.filtersis your UI side: an array of resolved filters, each your original config plus a livevalueand the handlers to change it. You render these; the library ships no components.
Both are derived from the URL query string, the single source of truth:
┌──────────────────── URL ────────────────────┐
│ ?status=open&search=acme&page=2 │
└───────────────────┬─────────────────────────┘
reads / writes │
useFilters
┌──────────────────┴──────────────────┐
params (to fetch) filters (to render)
{ status: 'open', … } [ { value, onChange, … }, … ]Because the URL is the source of truth, refreshes, the back button, bookmarks, and
shared links all just work, with no extra persistence layer. And since params changes
exactly when the URL changes, it's a correct cache key for React Query, SWR, or any
fetcher.
One value, three views
A single filter exposes its value at three points, and knowing which to read is most of using this library well:
| You read… | To get… |
|---|---|
filter.value | what the control should show now (the draft) |
filter.committedValue | what's actually applied and in the URL |
params.<key> | the same committed value, for fetching |
For a normal (instant) filter these are always equal: a change flows straight to
the URL. They only diverge when you defer a commit (a debounced search box, or a
manual "Apply" panel), where value reflects the in-progress control while
committedValue / params still hold the last applied value. That's covered in
Values & commits.
Where the pieces come from
Two more outputs round out the return:
filterMap: the same resolved filters keyed by their config key (filterMap.status), for reaching one filter directly instead of iterating.isFiltered,isDirty,apply,cancel,reset,setFilter,meta: whole-set state and actions, introduced where they're relevant.
That's the entire model. The rest of the docs is detail on top of it: which filter types exist, how to render them, and how to share this setup across your app and your route loaders.