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'
});-
parseapplies the surface's allow-lists so malformed URLs collapse to defaults, and resolvestimePeriodthroughresolveTimePeriodfrom@repo/core: explicit URL value > account default > 24-month global floor. -
buildomits values at their defaults so default selections produce clean, param-free URLs. FortimePeriodthe 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. -
filterKindclassifies every key:'server'— the page's server data fetch consumes it. A change writeshistory.pushState, notifies subscribers, thenrouter.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 onreplaceStateand 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 })} />filtersis reactive and SSR-correct: the server snapshot comes from Next'suseSearchParams, so on these dynamic routes the filter bars render the right values during SSR — inpage.tsxandloading.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'srouter.pushis 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.isPendingis true while a server-keyrouter.pushis in flight. It is per-hook-instance (oneuseTransitionper call) — when several siblings must dim on the same pending state, call the hook once in a provider and share it (seeDashboardUrlProvider).
Sharp edges
- Heavy consumers of high-frequency client keys need
useDeferredValue.useSyncExternalStoreupdates render synchronously (they opt out of time-slicing), so a search box that re-filters a chart per keystroke should derive fromuseDeferredValue(filters)and feed the live values only to the input controls.EngageContentis the reference. useSearchParamsneeds 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.pushStateelsewhere won't notify subscribers (callnotifyUrlChange()if you genuinely must), and arouter.pushfor a filter change bypasses classification.
Adding a filter to an existing surface
- Add the key to the surface's filters interface (e.g.
AssessmentFilters). - The
filterKindsatisfiesnow fails to compile — classify the key. - Teach
parseits allow-list + default, andbuildits omit-at-default rule. - 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 toEngageControls). - If the key is
'server', thread it throughpage.tsxinto 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.tsxand the writer↔reader integration test indashboard/lib/use-url-state-integration.test.tsx(its no-oprouter.pushmock is deliberate — read the comment before "fixing" it). - Per surface:
search-params.test.tscovers parse (and build for dashboard/assess — engage's build lives with its hook inuse-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.