Build pages where reload doesn't erase what the user was doing
By the end of this recipe you'll have a list page where filter chips, search query, selected row, and expanded sections all serialise to the URL, re-hydrate on mount, and copy clean to "share this view" — so a refresh, a back-button, or a pasted link all land the user exactly where they were.
Overview
Every "open" / "selected" / "expanded" state writes to the URL via useUrlState. On mount, the component reads the URL and hydrates its initial state. A "Share this view" button copies the current URL to the clipboard. That's the whole pattern.
SMB operators live in two browser tabs and a half-dozen WhatsApp threads. They will reload your page, hit back, and send links to their bookkeeper at 2am. If "reload" wipes the four filters they spent forty seconds setting, they will not blame the browser — they will blame you, and rightly so. Persisting state through the URL is the bare minimum a serious app can do.
If reload erases it, the user loses trust. Every "open" / "selected" / "expanded" UI state belongs in the URL. Local state is for transient things — keystrokes mid-typing, hover affordances, drag previews. Everything else is a query param.
The mechanism is one hook (useUrlState) with a schema. The schema declares each param's type and parser. The hook reads from location.search on mount, writes back with history.replaceState on change (so the back button doesn't pile up history entries for filter toggles), and exposes a typed [state, setState] tuple. No router needed.
Skip this recipe if your page is a single-purpose form (sign-up, sign-in, settings) — there's nothing to share, nothing to restore. Those pages should not be URL-stateful.
What you'll build
Three states: fresh load with filters from URL, share button, restored after reload.
/suppliers?tier=A&q=acme&sel=acme-metals&open=billing
Suppliers
Search: "acme"
Suppliers
Copied to clipboard:
huchu.app/suppliers?tier=A&q=acme&sel=acme-metals&open=billing
Reloaded · same state
/suppliers?tier=A&q=acme&sel=acme-metals&open=billing
Reload key F5 → no loss
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: useUrlState, FilterChips, Button, useToast. Everything else is plain React.
What goes in the URL, what stays in state
| Kind of state | URL? | Why |
|---|---|---|
| Active filter chips | yes | Shared, bookmarked, restored. |
| Search query | yes | Same — "show me what they're looking at". |
| Sort column / direction | yes | Affects what's on screen. |
| Selected row id | yes | Master-detail deep-link. |
| Expanded sections | yes | "What were you reading?" |
| Pagination cursor | yes | Bookmark page 4. |
| Mid-typing input value | no | Per-keystroke writes pollute history. |
| Hover / focus ring | no | Transient — not part of "the view". |
| Drag preview position | no | Local-only animation state. |
| Toast queue | no | Session-scoped, dismissed on reload. |
Step-by-step build
Declare the URL schema once
The schema is a typed dictionary: each key maps to a type (string, number, boolean, string[]) and an optional default. useUrlState uses the schema both to parse on mount and to serialise on change — so an unknown query param just gets dropped, and a missing one falls back to the default.
Unknown params → ignored. Missing params → defaults.
Switch to the Code tab to see the snippet.
Read once, write on intent
The hook does a history.replaceState on every setState — not pushState. That's deliberate: each filter toggle would otherwise add a back-button entry, and pressing Back would walk the user through every keystroke. Use pushState only for genuine page transitions (open a record, leave a record).
pushState for "open a record" → Back closes the record
Switch to the Code tab to see the snippet.
Debounce the search input
The search input is the one place where local state matters — you don't want a URL write per keystroke. Keep a local query for typing, push it to the URL after 250ms of quiet. On Back/Forward, sync local back to URL so the input doesn't show stale text.
s.q → URL truth (debounced 250ms)
URL → draft sync handles Back/Forward.
Switch to the Code tab to see the snippet.
Add "Share this view"
One button, one navigator.clipboard.writeText(location.href), one confirmation toast. Make sure the URL is canonical first — strip empty/default values so the link doesn't have ?q=&tier=&dir=asc noise. useUrlState already does this when you serialise, so the address bar matches the share link.
Final composition
The whole SuppliersList page: URL schema, debounced search, filter chips, selected row, share button.
Suppliers
Variations
Three forks.
Hash routing
If your backend can't route arbitrary paths, put everything after #. useUrlState reads location.hash instead of location.search.
useUrlState<State>(schema, {
source: 'hash', // default is 'search'
});
// URL: /suppliers#tier=A&sel=acme
Saved views
Let users name a view. Persist {name, url} pairs to the backend; render a "My views" menu in the page header.
const saveView = async () => {
const name = prompt('Name this view');
if (!name) return;
await fetch('/api/views', { method: 'POST',
body: JSON.stringify({ name,
path: window.location.pathname + window.location.search }) });
};
Browser-only persistence
For private state that shouldn't end up in a shared URL — e.g. a draft note — mirror to sessionStorage instead. Same hook shape, different write target.
useUrlState<State>(schema, {
source: 'sessionStorage',
key: 'huchu:suppliers:draft',
});
// Survives reload, not shareable.
Accessibility
What this recipe takes care of for you.
- Reload restores focus. If the URL has
sel=acme-metals, the matching row getsscrollIntoView+ focus on mount — a keyboard user lands exactly where they were. - Selected row exposes state.
aria-selected={s.sel === r.id}on the row, so screen readers say "Acme Metals, selected" without you needing a separate label. - Expanded sections are real disclosures.
aria-expanded={openId === id}on the toggle button; assistive tech announces "billing, expanded" / "billing, collapsed". - Share button confirms via live region. The success toast uses
role="status"+aria-live="polite"so users hear "Link copied" without a visual cue. - Back/Forward update state announce-ably. The URL sync triggers a re-render; if the filter chips change, their
aria-pressedupdates and screen readers pick it up. - Filter and search labels are explicit.
aria-label="Search suppliers"on the input; FilterChips groups carry arole="group"with the group name. - Clipboard fallback works without HTTPS. If
navigator.clipboardisn't available (insecure context on older Android), the recipe falls back to a hiddentextarea+execCommand.