Core concepts

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' })
});
  • params is 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.
  • filters is your UI side: an array of resolved filters, each your original config plus a live value and 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.valuewhat the control should show now (the draft)
filter.committedValuewhat'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.

On this page