Skip to main content

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.

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​

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: true because 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 holding filters. 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= stores DIMENSION:key only; 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.

Next: 20 — The 3D landscape and the PDF button.