A user opened the statutory-deductions tab on a payroll settings page, found the professional-tax field, and pasted the link into a support thread. The support person opened it on the general tab, three tabs away, and asked where the field was.
That was the bug in full. The active tab lived in a useState and never touched the URL, so the link carried none of it. We'd seen the symptom for weeks without naming it: people screenshotting views instead of sharing links, because the links didn't work.
The rule we wrote down
Any in-page state a user might bookmark, share, or land on directly goes in the URL.
Tabs, accordion sections, wizard steps, filter selections, sort order. If a person could reasonably want to return to exactly this view or hand it to someone else, the URL encodes it. Otherwise the state is trapped in memory and dies on refresh. The test we used: could someone want to link to this? If yes, it's URL state, not component state.
Hash versus query param
Two places to put it, and the choice isn't arbitrary. We got it wrong once in each direction before settling on the rule.
Hash for client-only view state. The server doesn't need to know which tab is open to render the page, so the tab slug goes in the fragment:
/settings/payroll#statutory
Query param when the state must survive SSR or drive a refetch. We had a reports page where the date range determined the data, and we first put the range in the hash. The server rendered the default range, then the client corrected it after hydration, and every load flashed the wrong month. Moving it to the query string fixed it, because now the server saw the selection on the first request:
/reports?range=q2&status=open
The distinction is: does the server need this to produce the right first paint? Query param. Is it purely which view the client shows? Hash.
There's a third case that tripped us: filter state that drives a refetch but is also long and ugly. A reports page had five filters — date range, status, region, product line, sort order — and putting all five in the query string produced URLs like /reports?range=q2&status=open®ion=west&line=footwear&sort=-total. That's correct and shareable, but it's also the exact thing that breaks when someone renames a field. We kept them as individual query params anyway, because the alternative — packing them into one opaque ?state=<base64> blob — makes the URL un-editable by a human and un-diffable in a PR. A person can read status=open and change it to status=closed in the address bar. Nobody can hand-edit base64. Legibility won over prettiness.
One more distinction the hash-versus-query rule doesn't cover: state that should survive a refresh but not a share. We didn't find a clean case for it on this app, so we didn't build one. Everything either went in the URL or stayed in useState and was allowed to die on refresh. Adding a sessionStorage tier for "personal, not shareable" state was tempting and we rejected it — it's a third place to look for state and a third thing to keep in sync, and no screen actually needed it.
Read on mount, reflect on change
Two halves, both required. Read the URL when the component mounts, so a shared link or a refresh opens the right state:
const [tab, setTab] = useState(
() => new URL(location.href).hash.slice(1) || "overview"
);
Reflect changes back into the URL with replaceState, not pushState:
function selectTab(slug: string) {
setTab(slug);
const url = new URL(location.href);
url.hash = slug;
history.replaceState(null, "", url); // no history spam, no scroll jump
}
We shipped pushState first. Then a tester clicked through eight tabs on a settings page and hit back, and the back button walked them through all eight before leaving the page. replaceState updates the URL in place, so the link is always current and the back button does what people expect. Using replaceState instead of a router navigation also skips the scroll-to-top and re-render a navigation triggers — the URL changes, the page doesn't jump.
The mount read has an edge case that bit us on the accordion. A shared link pointed at a section slug that no longer existed after a content edit — #prof-tax when the section had been split into two. The naive read set the open section to prof-tax, matched nothing, and rendered every section collapsed with no indication why. We now validate the slug against the known set on mount and fall back to the first item:
const VALID = new Set(["overview", "statutory", "prof-tax"]);
const initial = () => {
const slug = new URL(location.href).hash.slice(1);
return VALID.has(slug) ? slug : "overview";
};
const [tab, setTab] = useState(initial);
An unknown slug now opens the default view instead of an empty page. Same guard on the query-param side: an unrecognized range=q9 falls back to the default range rather than fetching an empty result set. A shared link that's slightly stale should degrade to something sensible, not a blank screen.
We built it into the primitive once
The first version was per-page, by hand, in about six places. They drifted immediately. One page used activeTab, another used tab, one forgot the mount read entirely so shared links opened on the default.
So we pulled it into the shared Tabs and Accordion components. Each item gets a stable kebab-case slug. The primitive reads the URL on mount and writes it on change, and every page that uses it gets deep-linking for free:
<Tabs urlKey="tab">
<Tab slug="overview">...</Tab>
<Tab slug="statutory">...</Tab>
</Tabs>
Pulling it into the primitive also let us fix the mount-read validation, the replaceState behavior, and the scroll-jump suppression in one place instead of six. When a seventh page needed tabs, it got all three fixes for free by using the component. The six hand-rolled versions had drifted on every one of those points — one used pushState, one didn't read the URL on mount, one scrolled to top on every tab click because it used a router push. Consolidating didn't just remove duplication, it removed six different half-correct implementations of the same behavior.
The wizard was the case that justified the whole exercise. A four-step onboarding flow needed each step addressable so support could send a user straight to step 3 with their partial state intact. Hand-rolling that per-step would have been four more chances to forget the mount read. Built on the same primitive, ?step=payment-details just worked, and the back button stepped through the wizard the way a user expects instead of the way pushState on every tab click had trained us to dread.
Slugs are contract-stable
One thing bit us after the fact: those slugs are as permanent as routes. #statutory was in bookmarks and a handful of Slack threads by then. Someone renamed it to #statutory-config in a cleanup PR, and every one of those links started landing on the default tab silently. No error, no redirect, just the wrong view for anyone who'd saved the old link.
We reverted the rename and added a note in the component: a slug is a promise, treat renaming one like renaming a route — a breaking change with a migration, not a casual refactor. Kebab-case, descriptive, decided once. The payoff is state that's shareable and refresh-proof across the whole app, built in one place. The cost is remembering the slug is load-bearing. Cheap trade.
When a rename is genuinely needed, we handle it the way we'd handle a moved route — with a redirect, not a silent break. The mount read checks an alias map before falling back to the default, so an old slug maps forward to the new one and the shared link still lands correctly:
const ALIAS = { "statutory": "statutory-config" }; // old -> new
const raw = new URL(location.href).hash.slice(1);
const slug = ALIAS[raw] ?? raw;
A three-line alias entry keeps every bookmarked and Slack-shared link working through a rename. It's the same discipline as a URL redirect: you don't get to break a link someone saved just because you renamed the thing behind it. The alias can be removed once the old links have aged out, the same way you'd retire a redirect, but you add it first and remove it deliberately, not the other way around.