Sharing setup across your app
createFilters for project constants, and defineFilters to bind a screen's config once.
Two helpers let you write your conventions down once instead of on every call.
createFilters: project constants
Pagination key names, page defaults, and date format are usually the same across an
app. Bind them once with createFilters and export the result; every screen then
imports a pre-configured useFilters / f / resolveFilterParams:
// src/lib/filters.ts
import { createFilters } from '@mbsatimov/use-filters';
export const { useFilters, resolveFilterParams, f, toDateValue, fromDateValue } = createFilters({
pagination: { pageKey: 'page', perPageKey: 'per_page', defaultPerPage: 25 }
});Import from your module, not the package
The package root also exports useFilters, defineFilters, f, and friends: a separate,
zero-config instance kept for apps that never call createFilters. Both import paths compile,
but only yours knows your pagination keys and request.arrayFormat. Once you have a
lib/filters.ts, always import from it; a stray from '@mbsatimov/use-filters' silently gives
you the defaults. Destructure everything you use there, including defineFilters.
// anywhere else
import { f, useFilters } from '@/lib/filters';Why a factory, not a React provider?
The same constants must reach resolveFilterParams, which runs in route
loaders outside React and can't read context. Closing over them
keeps the hook and the loader in lockstep, with full type-safety and no runtime lookup.
See the createFilters reference for every option
(pagination keys, firstPage, date serialization, arraySeparator, defaultCommit).
One factory per API convention
Different backends often paginate differently. One uses page / per_page,
another page / page_size (0-based). The pagination keys are deliberately not
per-call overridable, so a call and its loader can't drift. Instead, make one
factory per convention and name the exports:
export const { useFilters: useCrmFilters, resolveFilterParams: resolveCrmParams } = createFilters({
pagination: { pageKey: 'page', perPageKey: 'per_page' }
});
export const { useFilters: useBillingFilters, resolveFilterParams: resolveBillingParams } =
createFilters({ pagination: { perPageKey: 'page_size', firstPage: 0 } });defineFilters: bind one screen's config
useFilters and resolveFilterParams also share a per-call option,
{ arraySeparator, pagination }, that must match between them, and is easy to set
on one call and forget on the other. defineFilters binds a screen's config and
that option once, returning a hook and loader helper that can't disagree:
// src/features/products/filters.ts
export const productFilters = defineFilters(
{
search: f.text({ label: 'Search' }),
brand: f.select({ label: 'Brand', valueType: 'string', options: brandOptions })
},
{ arraySeparator: '|' }
);
// component: hook-only options still allowed per call:
const { params, filters } = productFilters.useFilters({ defaultCommit: 'manual' });
// route loader: same config and separator, guaranteed:
const params = productFilters.resolveFilterParams(new URL(request.url).searchParams);useFilters still takes hook-only options per call (defaultCommit, meta,
history, shallow, clearOnDefault); those can't affect resolveFilterParams,
so they're safe to vary. Only arraySeparator and pagination are locked at the
defineFilters call. It's available on every createFilters(...) instance and the
default export.
Since defineFilters already binds the config both sides share, it's the natural
place to validate against your API type: defineFilters<ProductListParams>(configs)
checks the config once and gives the bound hook and loader params typed as
exactly that type. See Typed params.