Changelog
Release history for @mbsatimov/use-filters, generated from Changesets.
1.1.1
Patch Changes
- 7797590:
f.asyncSelect/f.asyncMultiSelect:valueTypenow drives the value type. Previously the value type was inferred only fromloadOptions, so a callback that could resolve toundefined(e.g.list?.results.map(...)without a?? []fallback) silently widenedparams.<key>tostring | number | nulleven withvalueType: 'number'. NowvalueType: 'number'pins the param tonumber | null, and aloadOptionsthat resolves to anything other thanFilterOption<V>[]is a compile error at its own line. Internally the four choice builders now shareChoiceInput/ChoiceResulthelper types instead of repeating theirOmit/conditional shapes.
1.1.0
Minor Changes
-
1b38f19: Add
request.arrayFormattocreateFilters— control how array-shaped params (multiSelect,tags,numberRange, date/time ranges,asyncMultiSelect) appear inparams.Most backends expect array values as a comma-separated string, but
paramsalways exposed a JS array — forcing a manual join before every request. Setrequest.arrayFormat: 'string'andparamshands you the joined string directly (usingarraySeparator), fully typed asstring. The request shape is now a per-API constant alongsidepaginationanddate. The default stays'array', so this is non-breaking.Only
paramsis affected —filters/filterMapstill expose arrays for your UI, andparamsStris unchanged. Applies to bothuseFiltersandresolveFilterParams.const { useFilters } = createFilters({ request: { arrayFormat: 'string' } }); // tags: ['a', 'b'] → params.tags === 'a,b' (typed as string)
1.0.0
Major Changes
-
795f562: ### Changed
-
useFilters<P>now enforces a contract derived fromP's own shape — andparamsis typed as exactlyP(wasPartial<P>, which was unsound: unset filters arenullat runtime, neverundefined). The same<P>works identically onresolveFilterParams<P>anddefineFilters<P>(both previously inference-only), so a shared config can be validated once and produceP-shaped params in the hook and the loader alike.The obligations mirror the type:
- a required key in
Pmust have a filter declared; optional (?:) keys may omit one (the key is then absent fromparams). - a non-nullable param must set
defaultValue(its value can never benull); a| nullparam may leave it unset.
interface ProductListParams { status: 'open' | 'closed' | null; // filter required, default optional sort: 'date' | 'price'; // filter AND defaultValue required search?: string | null; // filter optional, default optional page: number; per_page: number; } const { params } = useFilters<ProductListParams>({ status: f.select({ label: 'Status', valueType: 'string', options: statusOptions }), sort: f.select({ label: 'Sort', valueType: 'string', options: sortOptions, defaultValue: 'date' }), search: f.text({ label: 'Search' }) }); productApi.getAll(params); // ✓ compiles — soundly: every claim `P` makes is true at runtimeMigration: params your API allows to be absent/unset should be declared optional and/or
| null(search?: string | null); non-nullable params need adefaultValueon their filter; every required key needs a filter. Configs that previously compiled against aPwith plain optionals (search?: string) now error until one of those is applied — each error names the missing key or the missingdefaultValue.For validation with full per-config inference (precise literal types, non-null defaulted params), check the config with
satisfies FiltersFor<P>and calluseFilters(configs)without the type argument. - a required key in
-
-
08265b3: ### Removed
FilterOption.icon/leftSlot/rightSlot, and the per-filterclassName— rendering hints don't belong on a headless core. Declare what you need onFilterOptionMeta/FilterMetaand pass it viametainstead (same data, typed by you). See UI metadata.unitonf.number/f.numberRange— same reasoning; declare aunitfield onNumberFilterMeta/NumberRangeFilterMetaand pass it viameta.
Changed
-
valueTypeis now required onf.select,f.multiSelect,f.asyncSelect, andf.asyncMultiSelect—'number' | 'string', checked againstoptions(a mismatched option is a compile error on that option).// before status: f.select({ label: 'Status', options: statusOptions }); // after status: f.select({ label: 'Status', valueType: 'string', options: statusOptions });Previously the value type was inferred from
options/defaultValuewhen present, and fell back to a runtime sniff with a dev-mode warning when it couldn't be determined (e.g. options fetched at runtime and not yet loaded) — a sniff that could disagree between the hook (options loaded) andresolveFilterParamsin a route loader (options empty), silently splitting a query key. RequiringvalueTypemakes that class of bug a compile error instead of a runtime footgun, and removes the sniffing/indeterminate-warning machinery entirely — simpler internals, one way to declare a choice filter's value type.
-
c7e7cf5: ### Removed
onClearon resolved filters — it was a deprecated alias forreset(the same function reference). Callreset()instead.
Changed
-
The hook's whole-set
reset()now respects each filter'scommitmode, matching how each filter's ownreset()already behaved. Previously the whole-setreset()bypassed commit modes and wrote to the URL immediately; now'instant'filters commit right away while'manual'/{ debounce }filters stage the cleared value as a draft and wait forapply(), exactly like any other change. This removes the surprise ofreset()and a filter's ownreset()doing two different things.For the previous "clear everything immediately, whatever the commit mode" behavior, use the new
instantReset()(below) — e.g. a toolbar "Clear all" button:// before — reset() always cleared immediately <button onClick={reset}>Clear all</button> // after — instantReset() is the immediate, mode-bypassing clear const { instantReset } = useFilters(configs); <button onClick={instantReset}>Clear all</button>
Added
instantReset()on the hook — clears every filter to its default in one batched URL write, bypassing commit modes and cancelling any pending drafts / debounce timers. The whole-set twin of each resolved filter's owninstantReset(), and the mode-bypassing counterpart to the hook'sreset()(the same relationshipsetFilterhas to a filter'sonChange).
Minor Changes
-
8301085: ### Added
-
listenersonuseFiltersoptions — declarative side-effect hooks, à la TanStack Form.onParamsChangefires (in an effect) whenever the committedparamschange, with the new params, the previous params, what triggered the change, and the whole hook API:useFilters(configs, { listeners: { onParamsChange: ({ params, prev, cause, api }) => { if (cause === 'reset') void api.post?.('/reset-event', params); } } });causeis'change' | 'reset' | 'external'('external'= a back/forward navigation, another URL consumer, or a pagination write you own).params,prev, andapiare fully typed from your config. It fires only on committed changes — never on mount, on a draft edit before commit, or when a change resolves to the same value. Exports theUseFiltersListeners,ParamsChangeContext, andParamsChangeCausetypes.
-
-
61c174f: ### Added
-
paramsStron theuseFiltersreturn —paramsserialized to a deterministic, sorted string, for use as a stable cache key or memo dependency when comparing a string is handier than an object:const { params, paramsStr } = useFilters(configs); // params { page: 1, per_page: 10, search: 'acme', status: 'open' } // paramsStr "page=1&per_page=10&search=acme&status=open" const { data } = useQuery({ queryKey: [paramsStr], queryFn: () => fetchList(params) });Keys are sorted (so config/key order never changes it), unset/empty filters are dropped (so equivalent states produce the same string), and values are URL-encoded (so special characters can't collide). Array values join with the call's
arraySeparator.
-
Patch Changes
-
9aac5c6: ### Fixed
-
params.<key>is now non-null when the filter declares adefaultValue. A filter with a default can never resolve tonullat runtime (nuqswithDefault, and reset/clear fall back to the default), but the type still included| null. Thef.*builders now capture whether adefaultValuewas given and drop| nullfrom that key's value type — everywhereparamsis derived (the hook,resolveFilterParams, and the resolved filter'svalue/committedValue):const { params } = useFilters({ search: f.text({ label: 'Search' }), // string | null (no default) per_page: f.number({ label: 'Per page', defaultValue: 25 }), // number (never null) status: f.select({ label: 'Status', valueType: 'string', options, defaultValue: 'open' }) // Status (never null) });Filters without a default are unchanged (
V | null). A defaulted filter is still clearable — itsonChangestill acceptsnull, which resets it to the default. Works across every builder (text,number,boolean, ranges, dates/times,select/multiSelect,asyncSelect/asyncMultiSelect,tags).
-
1.0.0-beta.0
Major Changes
-
795f562: ### Changed
-
useFilters<P>now enforces a contract derived fromP's own shape — andparamsis typed as exactlyP(wasPartial<P>, which was unsound: unset filters arenullat runtime, neverundefined). The same<P>works identically onresolveFilterParams<P>anddefineFilters<P>(both previously inference-only), so a shared config can be validated once and produceP-shaped params in the hook and the loader alike.The obligations mirror the type:
- a required key in
Pmust have a filter declared; optional (?:) keys may omit one (the key is then absent fromparams). - a non-nullable param must set
defaultValue(its value can never benull); a| nullparam may leave it unset.
interface ProductListParams { status: 'open' | 'closed' | null; // filter required, default optional sort: 'date' | 'price'; // filter AND defaultValue required search?: string | null; // filter optional, default optional page: number; per_page: number; } const { params } = useFilters<ProductListParams>({ status: f.select({ label: 'Status', valueType: 'string', options: statusOptions }), sort: f.select({ label: 'Sort', valueType: 'string', options: sortOptions, defaultValue: 'date' }), search: f.text({ label: 'Search' }) }); productApi.getAll(params); // ✓ compiles — soundly: every claim `P` makes is true at runtimeMigration: params your API allows to be absent/unset should be declared optional and/or
| null(search?: string | null); non-nullable params need adefaultValueon their filter; every required key needs a filter. Configs that previously compiled against aPwith plain optionals (search?: string) now error until one of those is applied — each error names the missing key or the missingdefaultValue.For validation with full per-config inference (precise literal types, non-null defaulted params), check the config with
satisfies FiltersFor<P>and calluseFilters(configs)without the type argument. - a required key in
-
-
08265b3: ### Removed
FilterOption.icon/leftSlot/rightSlot, and the per-filterclassName— rendering hints don't belong on a headless core. Declare what you need onFilterOptionMeta/FilterMetaand pass it viametainstead (same data, typed by you). See UI metadata.unitonf.number/f.numberRange— same reasoning; declare aunitfield onNumberFilterMeta/NumberRangeFilterMetaand pass it viameta.
Changed
-
valueTypeis now required onf.select,f.multiSelect,f.asyncSelect, andf.asyncMultiSelect—'number' | 'string', checked againstoptions(a mismatched option is a compile error on that option).// before status: f.select({ label: 'Status', options: statusOptions }); // after status: f.select({ label: 'Status', valueType: 'string', options: statusOptions });Previously the value type was inferred from
options/defaultValuewhen present, and fell back to a runtime sniff with a dev-mode warning when it couldn't be determined (e.g. options fetched at runtime and not yet loaded) — a sniff that could disagree between the hook (options loaded) andresolveFilterParamsin a route loader (options empty), silently splitting a query key. RequiringvalueTypemakes that class of bug a compile error instead of a runtime footgun, and removes the sniffing/indeterminate-warning machinery entirely — simpler internals, one way to declare a choice filter's value type.
-
c7e7cf5: ### Removed
onClearon resolved filters — it was a deprecated alias forreset(the same function reference). Callreset()instead.
Changed
-
The hook's whole-set
reset()now respects each filter'scommitmode, matching how each filter's ownreset()already behaved. Previously the whole-setreset()bypassed commit modes and wrote to the URL immediately; now'instant'filters commit right away while'manual'/{ debounce }filters stage the cleared value as a draft and wait forapply(), exactly like any other change. This removes the surprise ofreset()and a filter's ownreset()doing two different things.For the previous "clear everything immediately, whatever the commit mode" behavior, use the new
instantReset()(below) — e.g. a toolbar "Clear all" button:// before — reset() always cleared immediately <button onClick={reset}>Clear all</button> // after — instantReset() is the immediate, mode-bypassing clear const { instantReset } = useFilters(configs); <button onClick={instantReset}>Clear all</button>
Added
instantReset()on the hook — clears every filter to its default in one batched URL write, bypassing commit modes and cancelling any pending drafts / debounce timers. The whole-set twin of each resolved filter's owninstantReset(), and the mode-bypassing counterpart to the hook'sreset()(the same relationshipsetFilterhas to a filter'sonChange).
Minor Changes
-
8301085: ### Added
-
listenersonuseFiltersoptions — declarative side-effect hooks, à la TanStack Form.onParamsChangefires (in an effect) whenever the committedparamschange, with the new params, the previous params, what triggered the change, and the whole hook API:useFilters(configs, { listeners: { onParamsChange: ({ params, prev, cause, api }) => { if (cause === 'reset') void api.post?.('/reset-event', params); } } });causeis'change' | 'reset' | 'external'('external'= a back/forward navigation, another URL consumer, or a pagination write you own).params,prev, andapiare fully typed from your config. It fires only on committed changes — never on mount, on a draft edit before commit, or when a change resolves to the same value. Exports theUseFiltersListeners,ParamsChangeContext, andParamsChangeCausetypes.
-
-
61c174f: ### Added
-
paramsStron theuseFiltersreturn —paramsserialized to a deterministic, sorted string, for use as a stable cache key or memo dependency when comparing a string is handier than an object:const { params, paramsStr } = useFilters(configs); // params { page: 1, per_page: 10, search: 'acme', status: 'open' } // paramsStr "page=1&per_page=10&search=acme&status=open" const { data } = useQuery({ queryKey: [paramsStr], queryFn: () => fetchList(params) });Keys are sorted (so config/key order never changes it), unset/empty filters are dropped (so equivalent states produce the same string), and values are URL-encoded (so special characters can't collide). Array values join with the call's
arraySeparator.
-
Patch Changes
-
9aac5c6: ### Fixed
-
params.<key>is now non-null when the filter declares adefaultValue. A filter with a default can never resolve tonullat runtime (nuqswithDefault, and reset/clear fall back to the default), but the type still included| null. Thef.*builders now capture whether adefaultValuewas given and drop| nullfrom that key's value type — everywhereparamsis derived (the hook,resolveFilterParams, and the resolved filter'svalue/committedValue):const { params } = useFilters({ search: f.text({ label: 'Search' }), // string | null (no default) per_page: f.number({ label: 'Per page', defaultValue: 25 }), // number (never null) status: f.select({ label: 'Status', valueType: 'string', options, defaultValue: 'open' }) // Status (never null) });Filters without a default are unchanged (
V | null). A defaulted filter is still clearable — itsonChangestill acceptsnull, which resets it to the default. Works across every builder (text,number,boolean, ranges, dates/times,select/multiSelect,asyncSelect/asyncMultiSelect,tags).
-
0.9.0
Minor Changes
-
485fcbc: ### Added
-
defineFilters— bind one screen's config map and its shared{ arraySeparator, pagination }call option once, for bothuseFiltersandresolveFilterParams, so the two can't drift out of sync:export const loanFilters = defineFilters( { search: f.text({ label: 'Search' }) }, { arraySeparator: '|' } ); // component: const { params } = loanFilters.useFilters({ defaultCommit: 'manual' }); // route loader — same arraySeparator, guaranteed: const params = loanFilters.resolveFilterParams(new URL(request.url).searchParams);Previously, passing the same
{ configs, options }to both calls by hand had no guard against one side being updated (say, a newarraySeparator) and the other forgotten — silently splitting the query key between the hook and the loader.defineFiltersreturns a bounduseFilters/resolveFilterParamspair wherearraySeparator/paginationcan only be set in one place; hook-only options (defaultCommit,meta,history,shallow,clearOnDefault) still vary peruseFilters()call, since they can't affectresolveFilterParamseither way. Available on everycreateFilters(...)instance and the default export. Exports theSharedFilterCallOptionstype.
-
0.8.0
Minor Changes
-
722ba8d: ### Added
AnyUseFiltersReturn— the return of anyuseFilterscall, whatever config produced it. For pass-through components (a shared filter toolbar, a debug panel, a mobile filter sheet) that receive auseFiltersreturn as a prop and read it opaquely: previously typing that prop required being generic over both the config map and the pagination shape (and understanding parameter contravariance to see why); now it's one non-generic type. Reads stay typed (filtersis the usualResolvedFilter[]); only what a key-agnostic component can't use anyway loosens (paramsvalues areunknown,setFilteris uncallable).
Fixed
- A narrowly-typed resolved filter is now assignable to the wide
ResolvedFilter— e.g.filterMap.status(with'open' | 'closed'options) can be passed to a component prop typedResolvedFilterwithout a cast. The value-taking handlers (onChange,onSelectOption,onSetOptions,onToggleOption) are now declared with method syntax, whose bivariant parameter checking permits exactly this widening; the unsafe reverse direction (wide where narrow is expected) is still rejected via the covariantvalueproperty.
-
722ba8d: Packaging, correctness, and headless-purity hardening.
Fixed
- CJS consumers no longer get mismatched (ESM-flavored) type declarations.
The
exportsmap now ships per-condition types (import→dist/index.d.ts,require→dist/index.d.cts), fixing the "Masquerading as ESM" resolution error undermoduleResolution: node16from CJS. Verified with@arethetypeswrong/cli, which now runs in CI and before every publish (npm run check:exports). - Dynamic
select/multiSelectfilters now re-parse correctly when options arrive after mount. Previously a backend-driven filter whose options started[]and later loaded numeric values kept its string parse forever —?ids=1,2stayed['1','2']and never matched whatresolveFilterParamscomputes in a route loader. Two-part fix: the structural parser fingerprint now includes the options' value family (numeric vs. string) so the parser map re-keys, and already-committed values are normalized through the current parser (nuqs caches parsed state per query string, so re-keying alone never re-parses an unchanged param). Content changes to a filter's per-filternuqsoptions are now fingerprinted too (previously only their presence/absence was). - Removed a false JSDoc claim that async
loadOptionsresults "are cached per search string" — they are debounced and abortable, not cached.
Added
valueTypeonselect/multiSelect. Choice filters now take an optionalvalueType: 'number' | 'string'— a static declaration of how the value round-trips through the URL. The token is the declaration: when set it drives the value type (empty options still give anumber | nullparam undervalueType: 'number'), andoptionsare type-checked against it — a mismatched option is a compile error on that option. With static options and no token the type is still inferred, so nothing changes for the common case. It matters when options are fetched at runtime: the same config is then also used somewhere the options aren't loaded (a route loader callingresolveFilterParams), where the previous approach — sniffing the option values — had nothing to read and the hook and loader could parse the same URL to different types (5vs'5'), splitting the query key.valueTypeis the shared source of truth that keeps them identical.resolveFilterParamsnow warns (dev only) when it meets an options-less choice filter without avalueType, catching the divergence at its source. Exports theChoiceValueTypetype.resolveFilterParamsacceptsURLSearchParamsand raw query strings in addition to plain objects — passnew URL(request.url).searchParams(React Router) orlocation.searchdirectly. Exports theRawSearchParamstype.FilterOption.meta+ augmentableFilterOptionMeta— the per-option counterpart toFilterMeta, for project-specific option UI hints (icons, swatches, shortcut labels) without baking rendering concerns into the core.- Dev-mode
valueTypemismatch warning. An async filter whoseloadOptionsresolves options contradicting itsvalueType(e.g. string UUID ids under the numeric default) now warns once per filter in development — previously such values silently parsed back from the URL asnull. - Type-level tests (
expectTypeOf) locking inparamsinference, explicit<P>validation, renamed-pagination-key typing, and resolved-filter narrowing.
Deprecated (removal in 1.0)
FilterOption.icon/leftSlot/rightSlotand the per-filterclassName— rendering hints don't belong on a headless core. Declare the fields you need onFilterOptionMeta/FilterMetaand pass them viameta; behavior is unchanged until 1.0.
Changed
engines.nodeis now>=20(Node 18 is end-of-life). The library code is unchanged — this only affects tooling expectations.
- CJS consumers no longer get mismatched (ESM-flavored) type declarations.
The
-
722ba8d: ### Added
resetPageOnFilterChangepagination option. By default, changing any filter resets the page tofirstPage(a changed filter invalidates the old result window). Set it tofalse— increateFilters'spagination, or per call via thepaginationobject onuseFilters' options — when pagination is driven entirely outside the hook and it should never write the page param. Applies to the whole-setreset()too.
0.7.0
Minor Changes
-
014be90:
asyncSelect/asyncMultiSelectnow actually debounceloadOptions. Previously the option was documented but never read, so a search box callingonChangeon every keystroke firedloadOptionsonce per keystroke. Calls within the debounce window (default300) of each other now collapse into a single underlying call, using the last call's arguments — every caller in that window resolves/rejects together with that call's outcome.loadOptionsis wrapped internally; no change to the function you pass in.Breaking: the option is renamed from
debounceMstosearchDebounceMs, to distinguish it from a filter'scommit: { debounce }delay now that it actually does something.
0.6.0
Added
- Configurable array separator. The delimiter joining/splitting an
array-shaped param's items in the URL (
multiSelect,asyncMultiSelect,tags, and the range kinds) defaults to','as before, but is now overridable:createFilters({ arraySeparator: '|' })sets a project-wide default,useFilters(configs, { arraySeparator: '|' })overrides it per call, andresolveFilterParams's matching option keeps route-loader parity. An async multi-select's label sidecar uses the same separator as its value. Exports theDEFAULT_ARRAY_SEPARATORconstant (',').
0.5.0
Fixed
onClear/onChangeno longer mark adebounce/manualfilter dirty when the change is a no-op — clearing an already-empty (or already-at-default) filter, undoing a pending change back to its committed value, or toggling a multi-select option on then off all now leaveisDirtyfalse, matching what's actually committed. Previously any non-instantchange queued a pending entry unconditionally, so e.g. clicking "Clear" on an untouched filter incorrectly enabled the Apply button.
Changed (breaking)
-
useFilters's per-page override moved underpagination. The flatdefaultPerPageoption is gone; pass it inside thepaginationobject so the hook's options mirror thecreateFiltersconfig and override it per call:// before useFilters(configs, { defaultPerPage: 25 }); // after useFilters(configs, { pagination: { defaultPerPage: 25 } });pagination: false/trueare unchanged. OnlydefaultPerPageis overridable per call — the page/per-page keys andfirstPagestay factory-only (createFilters) so the hook'sparamsstill matchesresolveFilterParams.resolveFilterParams's options take the same shape (itsdefaultPerPagealso moves underpagination). Exports thePaginationOverridetype.
Added
- Default
commitmode. Set a fallbackcommitat the factory (createFilters({ defaultCommit })) or per call (useFilters(configs, { defaultCommit })) instead of repeating it on every filter. Precedence: per-filtercommit→useFiltersdefaultCommit→createFiltersdefaultCommit→'instant'. Each resolved filter now exposes its effective mode asfilterMap[key].commit. - Per-filter state, exposed directly on every resolved filter — no more
cross-referencing
paramsby key or re-derivingcommitmode yourself:isInstant/isDebounced/isManual,debounceMs,isDirty(this filter specifically has an uncommitted change),committedValue(its actual value inparams/the URL, independent of any pending draft),isFiltered(this filter's own active/inactive state, based on its committed value), andisFilteredDraft(the same check against the draft value — use it for a "Clear" button that should react instantly, sinceisFilteredstill shows the old committed state until acommit: 'manual'change is applied).commitis now typed as always-present on a resolved filter (it was previously typed optional, inherited from the config). - Per-filter
apply()/cancel()/reset()/instantReset()— the same trio as the hook's whole-set versions, scoped to one filter, plus a fourth:apply()/cancel()commit or discard just that filter's pending change (a no-op if it isn'tisDirty);reset()sets it back todefaultValue(or empty), respecting itscommitmode like any other change — on a manual filter it lands in the draft and waits forapply();instantReset()does the same but bypassescommitand writes straight toparams/the URL now, mirroring howsetFilterrelates toonChangeat the hook level. (The hook's whole-setreset()also bypassescommitentirely — it's the multi-filter equivalent ofinstantReset(), notreset().)onClearis now a deprecated alias forreset(identical function) — existing code keeps working unchanged. - The playground demo now uses Tailwind CSS
v4 and shadcn/ui components, and demonstrates the
new per-filter
apply/cancel/resetUI. Dev-only — not part of the published package.
0.4.0
Added
- Deferred commits — per-filter
commitmode. Each filter takes acommitoption controlling when its change reachesparams/the URL:'instant'(default, unchanged behavior),{ debounce: ms }(commitmsafter the last change), or'manual'(commit only onapply()).useFilterskeeps a local draft so the control stays responsive while the committed value waits — no extra state to wire up for a debounced search box or a mobile "Apply filters" sheet. Addsapply(),cancel(), andisDirtyto the hook's return, and exports theFilterCommitModetype.setFilterbypasses the draft and commits immediately. - Interactive playground / live demo covering every filter kind and all three
commit modes. Dev-only (
npm run playground, deployed to Vercel) — not part of the published package.
0.3.0
Changed (breaking)
-
createFiltersconfig is now grouped by concern. Pagination options move under apaginationobject and date options under adateobject. Flat top-level keys are no longer accepted:// before createFilters({ pageKey: 'page', pageSizeKey: 'per_page', serializeDate, parseDate }); // after createFilters({ pagination: { pageKey: 'page', perPageKey: 'per_page' }, date: { serialize, parse } });The date hooks are also renamed (
serializeDate/parseDate→date.serialize/date.parse; the*DateTimepair keeps its name underdate). -
Pagination params now mirror the URL keys. Previously
paramsalways used{ limit, offset }regardless ofpageKey/pageSizeKey. NowpageKey/perPageKeyname both the URL query params and the pagination keys inparams— the defaultpage/per_pagekeys yieldparams = { …filters, page, per_page }, and renaming them updatesparamsto match (typed from the literal key names). -
page_size→per_page, and the pagination config identifiers were renamed. The default per-page URL/param key is nowper_page(waspage_size). The config options are renamed to match:pageSizeKey→perPageKeyanddefaultPageSize→defaultPerPage(on bothcreateFiltersand theuseFiltersoptions). -
Removed
mapPagination/toParams. With keys mirroring intoparams, the value-transform hook is gone. APIs whose pagination shape differs from the URL keys (e.g. offset-based) derive it at the fetch call fromparams:{ limit: params.per_page, offset: (params.page - 1) * params.per_page }. -
defaultPage→firstPage. Renamed and repurposed: it's the number the first page is counted from (the value when the URL has none, what reset writes, and the base the API pages from). Defaults to1; setfirstPage: 0for a 0-indexed API.
Added
f.time/f.timeRange— time-of-day filters with no date. Values are 24-hour clock strings (HH:mm, orHH:mm:sswithprecision: 'second') — what an<input type="time">reads/writes, so no converters and no timezone.timeRangemay wrap midnight (from > to). ExportsTimeFilterConfig,TimeRangeFilterConfig,TimeFilterMeta,TimeRangeFilterMeta.pagination.firstPage— control 0-based vs 1-based page numbering.- Exported
PaginationConfigandDateConfigtypes.
0.2.0
First public release on npm. Bug fixes, a smaller dependency footprint, three new filter capabilities, and substantially expanded docs.
Added
f.numberRange— numeric from–to filter ([number, number] | null), e.g. price/age "between". Supportsprecision: 'float' | 'int'andunit.f.tags— freeform multi-value string filter (string[] | null) with no predefined options and no server lookup.- Datetime support —
f.date/f.dateRangeacceptprecision: 'datetime'to capture a time component. AddsdateTimeFormat,serializeDateTime,parseDateTimeconfig, andtoDateTimeValue/fromDateTimeValueconverters (plus theDATE_TIME_FORMATexport). - Overridable date (de)serialization — dates use a fixed
yyyy-MM-dddefault (datetimeyyyy-MM-ddTHH:mm:ss); overrideserializeDate/parseDate(and their*DateTimecounterparts) oncreateFiltersto store dates in any shape or date library. - Test suite (Vitest) covering parsers,
resolveFilterParamsparity, async label sidecars, and the new kinds.
Fixed
- Number filters no longer truncate decimals.
f.numberparses floats by default; opt into integers withprecision: 'int'. - Dates round-trip correctly with a custom
dateFormat.fromDateValuenow parses with the configured format instead of the nativeDateconstructor (which misread e.g.dd.MM.yyyy), andcreateFiltersbinds it to that format. resolveFilterParamsproduces the same values as the hook. Raw search params are coerced through the same parsers (and page/size to integers), so a route loader's query key matches the hook's and the prefetch is reused.- Numeric select/multiSelect detection scans all options and falls back to
defaultValueinstead of sniffing only the first option. buildParsernow has an ergonomic single return type (nonever-typedparse/serializeat call sites).
Changed
- Zero runtime dependencies. Removed
lodash(replaced the singleisEqualuse with a small internal deep-equal) anddate-fns(replacedformat/parsewith fixedyyyy-MM-dd(de)serializers, overridable viaserializeDate/parseDate). The package now needs only thereactandnuqspeers. - Inline config objects passed to
useFiltersare fingerprinted structurally, so URL state is no longer re-initialized on every render. - Publishing moved to the public npm registry; licensed MIT.
- Docs rewritten as a full guide (setup, quickstart, filter-kinds reference, rendering, async/label sidecar, dynamic filters, API reference, gotchas), with expanded JSDoc across the public API.
0.1.0
- Initial (internal) release:
createFilters,useFilters, thef.*builders,resolveFilterParams,metaaugmentation, and nuqs-backed URL sync.