Guides

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.

On this page