19 — The URL is the state
Read this first: you will go looking for the analytics page's cross-filter store — the context,
the Redux slice, the prop-drilled callback chain — and you will not find one. The filter state IS the
URL: a 43-line hook over useSearchParams, plus two effects that make a half-open drilldown drawer
survive a refresh. By the end you can add a new cross-filter producer without adding any state.
Time: about 35 minutes. Assumes lesson 18.
The whole store is forty-three lines
This is useHrAnalyticsFilters.ts, complete:
import { useCallback } from 'react';
import { useSearchParams } from 'react-router';
export interface HrAnalyticsFilters {
/** Trailing window for trend charts. */
months: number;
/** Cross-filter key. Departments are keyed by name (the only dimension every
* department-aware endpoint exposes today), or null for "all departments". */
department: string | null;
setMonths: (m: number) => void;
setDepartment: (name: string | null) => void;
/** Toggle a department: click the active one again to clear the filter. */
toggleDepartment: (name: string) => void;
clearDepartment: () => void;
}
/**
* Holds the dashboard's filter state in URL search params so a view is
* shareable/bookmarkable and survives reload. Extends the page's original
* `?months=` with a `?dept=` cross-filter.
*/
export const useHrAnalyticsFilters = (): HrAnalyticsFilters => {
const [params, setParams] = useSearchParams();
const months = Number(params.get('months')) || 12;
const department = params.get('dept');
const patch = useCallback((mutate: (p: URLSearchParams) => void) => {
setParams((prev) => {
const next = new URLSearchParams(prev);
mutate(next);
return next;
}, { replace: true });
}, [setParams]);
const setMonths = useCallback((m: number) => patch((p) => p.set('months', String(m))), [patch]);
const setDepartment = useCallback((name: string | null) =>
patch((p) => (name ? p.set('dept', name) : p.delete('dept'))), [patch]);
const clearDepartment = useCallback(() => patch((p) => p.delete('dept')), [patch]);
const toggleDepartment = useCallback((name: string) =>
patch((p) => (p.get('dept') === name ? p.delete('dept') : p.set('dept', name))), [patch]);
return { months, department, setMonths, setDepartment, toggleDepartment, clearDepartment };
};
Three things to notice. There is no useState. Reads parse the params on every render; writes go
through patch, which copies the previous params, mutates the copy, and hands it back. The URL is
the single source of truth, which is why the doc comment leads with the payoff: "so a view is
shareable/bookmarkable and survives reload." Paste the link in Slack and your colleague opens your
exact view.
Every write passes { replace: true }. Filter clicks replace the current history entry instead
of pushing a new one, because filter clicks must not pollute the back button: with push semantics,
toggling five departments would cost five Back presses to leave the page. Navigation between pages
pushes; refinement within a page replaces.
The key is a name, not an id. The interface comment says why: department name is "the only
dimension every department-aware endpoint exposes today." A cross-filter key must be understood by
every consumer, so the hook keys on the one field they all share — and that decision will matter
again when ?drill= has to survive a colon inside a department name.
The ?dept= economy
HrAnalytics.tsx calls the hook once and passes the filters object down. From there, everything is
producers writing one param and consumers reading it:
producers the URL consumers
───────── ─────── ─────────
drawer "Focus this department" ─┐ ┌─▶ useHrHeadcountMovement(months, dept)
RankBars onSelect (contract) ─┼─▶ ?dept=Name ─┼─▶ panel subtitles: `monthly movement — Name`
header chip close (clears) ─┘ ├─▶ header chip appears in HrAnalytics.tsx
└─▶ RankBars dimming + 3D block highlight
The most important consumer is the data layer. In useReports.ts, the department is part of the React Query key:
export const useHrHeadcountMovement = (months = 12, department: string | null = null, enabled = true) =>
useQuery({
queryKey: ['reports', 'hr-analytics', 'headcount-movement', months, department],
queryFn: () => getHrHeadcountMovement(months, department),
enabled,
});
That is the entire refetch mechanism. A producer changes ?dept=, the hook re-renders with a new
department, the query key changes, React Query fetches the scoped data — the same
key-as-dependency pattern you met in lesson 04. No event bus, no
refetch() calls.
The cheap consumers ride the same render. OverviewTab derives one string and threads it through
panel subtitles:
const scopeNote = department ? ` — ${department}` : '';
HrAnalytics.tsx renders the removable chip only when the param exists, and clearing is just
another URL write:
{filters.department && (
<HStack>
<HStack /* … pill styling … */>
<Text>Department: {filters.department}</Text>
<Icon
as={LuX} boxSize={4} cursor="pointer" opacity={0.7} _hover={{ opacity: 1 }}
onClick={filters.clearDepartment} aria-label="Clear department filter"
/>
</HStack>
</HStack>
)}
And RankBars.tsx shows selection by
dimming everything else — its doc comment states the contract: "When selectable, clicking a bar
cross-filters the page and un-selected bars dim."
{data.map((row) => {
const cat = catOf(row);
const active = !hasSelection || cat === selected;
return <Cell key={cat} fill={hue} fillOpacity={active ? 1 : 0.28} />;
})}
On the Overview tab, bar and 3D-block clicks actually route to openDepartment — they open the
drilldown drawer from lesson 18 — and the drawer's focus action is
what writes ?dept=:
focusAction: {
label: department === name ? 'Clear focus' : 'Focus this department',
active: department === name,
onClick: () => toggleDepartment(name),
},
Here is the capability this lesson promised. Adding a producer is one function call. Any
component that receives filters (or calls the hook) can call toggleDepartment(name) — no new
state, no new context, no new prop chain. Every consumer already subscribes to the URL, so the new
producer is wired to all of them the moment it exists. That is exactly how the drawer's focus action
was added: three lines in a context builder, zero lines of state.
?drill=: a deep link that rebuilds itself
The drawer's open/closed state is real useState — but OverviewTab mirrors it into the URL so a
mid-drilldown view is shareable too. The serialized form is deliberately tiny:
/** URL form of a restorable drill (department drills only), e.g. `DEPARTMENT:Accounting`. */
const DRILL_PARAM = 'drill';
const drillParamFor = (ctx: DrilldownContext | null): string | null =>
ctx?.records?.dimension === 'DEPARTMENT' && ctx.records.key
? `DEPARTMENT:${ctx.records.key}`
: null;
Only department drills are restorable. A department drill can be rebuilt from a stable key — the
name — validated against data this tab always loads; the KPI and month drills would need a snapshot
of numbers that may have changed since the link was made. So the URL stores DIMENSION:key and
nothing else: the headline, the rows, the rank are all recomputed on restore.
Restoring and syncing must not fight each other, and one ref referees the match:
const [params, setParams] = useSearchParams();
const restoredRef = useRef(false);
// Every user-initiated drill goes through here: it consumes any pending
// deep-link restore (so a drawer the user opened/closed early can't be
// overridden by a late restore) and enables the URL-sync effect below.
const openDrill = useCallback((ctx: DrilldownContext | null) => {
restoredRef.current = true;
setDrill(ctx);
}, []);
Read that comment twice — it names the race. The restore effect below cannot run until the
department aggregates arrive. If the user opens (or opens and closes) a drawer during that window, a
late restore firing afterwards would stomp their action. openDrill wraps every user-initiated
open and burns the restore token first.
Predict: you are mid-drilldown on the Accounting department — drawer open, employee records showing — and you hit refresh. What reopens, and where does each piece of data come from? Write your answer down before reading the restore effect.
// Deep link: reopen a shared/bookmarked department drill (?drill=DEPARTMENT:name)
// once — after the data it derives from has loaded. A param that doesn't resolve
// to a known department is scrubbed right here: the sync effect below can't do
// it, because flipping a ref re-runs no effect.
useEffect(() => {
if (restoredRef.current || dLoading || otLoading || byDept.length === 0) return;
restoredRef.current = true;
if (drill !== null) return;
const raw = params.get(DRILL_PARAM);
if (!raw) return;
const [dimension, ...keyParts] = raw.split(':');
const key = keyParts.join(':');
if (dimension === 'DEPARTMENT' && key && byDept.some((x) => x.departmentName === key)) {
// One-shot restoration from an external system (the URL), not a render cascade.
// eslint-disable-next-line react-hooks/set-state-in-effect
openDepartment(key);
} else {
setParams((prev) => {
const next = new URLSearchParams(prev);
next.delete(DRILL_PARAM);
return next;
}, { replace: true });
}
}, [dLoading, otLoading, byDept, drill, params, openDepartment, setParams]);
Resolve the prediction. On refresh the React Query cache is empty, so the department and
overtime aggregates refetch; the effect waits them out via dLoading || otLoading. Once they land,
it reads ?drill=DEPARTMENT:Accounting, splits off the dimension but rejoins the key —
keyParts.join(':') — because department names may contain a colon, and the name is the key.
It checks Accounting still exists in byDept, then calls openDepartment('Accounting'), which
rebuilds the entire drawer context — headline, share of workforce, rank, overtime rows — from the
fresh aggregates. The records section inside the drawer then runs its own drilldown query and
refetches page 0, exactly as in lesson 18. Nothing was persisted
except sixteen characters of URL. And if the link is stale — the department was renamed or removed —
the param is scrubbed on the spot, with replace: true, for the same back-button reason as before.
A second effect closes the loop, keeping ?drill= current as the user opens and closes drawers:
// Keep ?drill= in step with the open drawer (set for department drills, cleared
// for other drills and on close) so the view stays shareable, like ?dept=/?months=.
useEffect(() => {
if (!restoredRef.current) return;
const want = drillParamFor(drill);
if (params.get(DRILL_PARAM) === want) return;
setParams((prev) => { /* … set or delete DRILL_PARAM, { replace: true } … */ });
}, [drill, params, setParams]);
The !restoredRef.current guard stops this effect from deleting the param before the restore has
had its one chance to read it.
An escape hatch, with its papers
The restore effect calls a state setter inside an effect, which React 19's hooks lint flags — most set-state-in-effect is a render cascade that should be derived state instead. The disable is there, but look at what travels with it:
// One-shot restoration from an external system (the URL), not a render cascade.
// eslint-disable-next-line react-hooks/set-state-in-effect
openDepartment(key);
The justification names the exemption it is claiming: synchronizing with an external system is the
documented purpose of effects, and the URL is an external system. The norm: an escape hatch is
acceptable only with its justification, on the line above, so the next reader can check the claim
instead of trusting the disable. A bare eslint-disable is a debt with no paper trail.
The page that forgets, and the page that doesn't
Close the loop with lesson 12: open an employee drawer on the
Employees grid, hit refresh, and you are back at page one with the drawer gone — that page keeps its
UI state in component state. This page does not lose your place, and nothing about the technique is
analytics-specific: a params-backed hook, replace: true, keys in query keys, a tiny serialized
form with a validity check. The pattern is portable — the grid pages just have not adopted it yet.
Where this shows up in MotorPH
- frontend/src/components/analytics/useHrAnalyticsFilters.ts — the whole store
- frontend/src/pages/hr/hr-analytics/OverviewTab.tsx —
?drill=restore and sync effects - frontend/src/pages/hr/HrAnalytics.tsx — the months select, the department chip,
filterspassed to tabs - frontend/src/components/analytics/charts/RankBars.tsx — the
selectabledimming contract - frontend/src/components/analytics/three/AnalyticsScene.tsx —
selected/onSelect, decoded in lesson 20 - frontend/src/hooks/api/useReports.ts —
departmentinside query keys - frontend/src/components/analytics/MetricDrilldownDrawer.tsx — where
focusActionrenders - the state reference — where URL state sits among the app's state layers
Recap
- The URL is the only filter store — a view that lives in
?months=&dept=is shareable, bookmarkable, and refresh-proof for free, with no context or store to keep in sync. - Every filter write uses
replace: truebecause refining a view must not pollute the back button; navigation pushes, refinement replaces. - A new cross-filter producer is one function call —
toggleDepartment(name)from any component holdingfilters. Consumers subscribe to the URL through query keys and props, so the producer needs zero new state and zero new wiring. - Serialize the key, rebuild the context —
?drill=storesDIMENSION:keyonly; the restore effect recomputes everything from refetched data, validates the key, and scrubs a stale link. - A lint disable ships with its justification — the set-state-in-effect escape is legitimate only because the comment above it proves it is URL synchronization, not a render cascade.