Fair Supply LogoFair Supply - Docs

URL Filter State

Every filtered surface follows one rule: the URL is the single source of truth for filter state. There is no Zustand store, no useState shadow, and no reconcile effect — components read filter values parsed from the URL and write changes back to it. One factory implements the mechanism; each surface supplies a small config.

            createUrlStateHook (apps/web/src/hooks/create-url-state-hook.ts)
            reads: useSyncExternalStore over window.location.search
                   (popstate + URL_UPDATE_EVENT; SSR snapshot from useSearchParams)
            writes: history write → notify → router.push when a 'server' key changed

        ┌─────────────────┼──────────────────────┐
        │                 │                      │
useDashboardUrlState  useAssessUrlState   useEngageUrlState
(dashboard/lib/       (assess/lib/        (engage/hooks/
 use-dashboard-        use-assess-         use-engage-
 url-state.ts)         url-state.ts)       url-state.ts)

The surface config

Each adapter passes a base path plus three behaviours:

export const useAssessUrlState = createUrlStateHook<AssessmentFilters>({
  basePath: '/assess',
  parse: parseSearchParams,    // URL params (+ account default) → full filter object
  build: buildSearchParams,    // filter object (+ account default) → params, omitting defaults
  filterKind: FILTER_KIND,     // per-key 'server' | 'client'
});
  • parse applies the surface's allow-lists so malformed URLs collapse to defaults, and resolves timePeriod through resolveTimePeriod from @repo/core: explicit URL value > account default > 24-month global floor.

  • build omits values at their defaults so default selections produce clean, param-free URLs. For timePeriod the omission baseline is the account default (isDefaultTimePeriod), not the global constant — otherwise an explicitly-chosen 24-months would be dropped and snap back to a differing account default on reload.

  • filterKind classifies every key:

    • 'server' — the page's server data fetch consumes it. A change writes history.pushState, notifies subscribers, then router.pushes in a transition to refetch. Each change is a history entry (Back steps through data views).
    • 'client' — only drives client-side display. history.replaceState + notify; no server round-trip, no history entry.

    The as const satisfies Record<keyof TFilters, FilterKind> is load-bearing: adding a filter key without classifying it is a compile error. That prevents the silent-stale-data bug where a new server-affecting key rides on replaceState and never refetches.

Params not named in filterKind (e.g. ?q= from the search overlay) are owned by sibling components and preserved on every write.

Using it in components

const { filters, updateUrl, isPending } = useAssessUrlState();

<Dropdown value={filters.timePeriod} onValueChange={(v) => updateUrl({ timePeriod: v })} />
  • filters is reactive and SSR-correct: the server snapshot comes from Next's useSearchParams, so on these dynamic routes the filter bars render the right values during SSR — in page.tsx and loading.tsx. That is why the route-level loading states render the real shells (filter bars are never skeletoned) with placeholders only for the regions that need data.
  • updateUrl(patch) writes the URL synchronously before any router work, so controlled inputs reflect a click immediately. Next 16's router.push is fully async (it updates the URL only after the RSC commit, firing no event), which is why the factory never relies on it for the URL write.
  • isPending is true while a server-key router.push is in flight. It is per-hook-instance (one useTransition per call) — when several siblings must dim on the same pending state, call the hook once in a provider and share it (see DashboardUrlProvider).

Sharp edges

  • Heavy consumers of high-frequency client keys need useDeferredValue. useSyncExternalStore updates render synchronously (they opt out of time-slicing), so a search box that re-filters a chart per keystroke should derive from useDeferredValue(filters) and feed the live values only to the input controls. EngageContent is the reference.
  • useSearchParams needs a Suspense boundary. Next forces one above any consumer when a route is statically rendered (PPR). Our filter routes are dynamic so it rarely fires, but pages and loading files wrap the shells in <Suspense fallback={null}> defensively — mirror that for a new surface.
  • Never write the URL around the factory. A raw history.pushState elsewhere won't notify subscribers (call notifyUrlChange() if you genuinely must), and a router.push for a filter change bypasses classification.

Adding a filter to an existing surface

  1. Add the key to the surface's filters interface (e.g. AssessmentFilters).
  2. The filterKind satisfies now fails to compile — classify the key.
  3. Teach parse its allow-list + default, and build its omit-at-default rule.
  4. Render a control bound to filters.x / updateUrl({ x }) — no store and no reconcile wiring (on engage, a control inside the suspended data region also means adding a setter to EngageControls).
  5. If the key is 'server', thread it through page.tsx into the surface's data fetch — classification makes the page refetch, not consume. A server key the fetch ignores refetches identical data: the same silent-stale-data bug, one layer down.

Adding a new surface

Create parse/build in the surface's lib/search-params.ts, a FILTER_KIND, and one createUrlStateHook call. Keep the filter bar in a shell component that renders above the data Suspense boundary (the dashboard model) so the bar is interactive while data streams, and reuse the shell in loading.tsx.

Convention, not a hard rule: dashboard and assess co-locate both parse and build in lib/search-params.ts. Engage deviates — parseEngageFilters lives in lib/search-params.ts but buildEngageParams sits with the hook in hooks/use-engage-url-state.ts (and its build coverage is in use-engage-url-state.test.ts). Either layout is fine as long as the pair is discoverable from the hook.

Tests

  • Mechanism: apps/web/src/hooks/__tests__/create-url-state-hook.test.tsx and the writer↔reader integration test in dashboard/lib/use-url-state-integration.test.tsx (its no-op router.push mock is deliberate — read the comment before "fixing" it).
  • Per surface: search-params.test.ts covers parse (and build for dashboard/assess — engage's build lives with its hook in use-engage-url-state.test.ts), including the account-default omission baselines and a build→parse round-trip identity test. The assess/engage shell tests prove the bars render URL values with no data provider mounted (the structural no-flip/no-suspend guarantee); dashboard's equivalent coverage is the integration test.

On this page