Home/ Cookbook/ Shells/ Deep-link state restore

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.

Shells 2 primitives · 0 blocks · 0 patterns ~25 min React · @corelithzw/react

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

/suppliers?tier=A&q=acme&sel=acme-metals&open=billing

Suppliers

Tier ATier BTier C

Search: "acme"

Acme Metals — billing open
Acme Hardware
01 Mount — read URL, hydrate state
Suppliers

Suppliers

Tier ATier B
Share this view

Copied to clipboard:

huchu.app/suppliers?tier=A&q=acme&sel=acme-metals&open=billing

02 Share — clipboard copy of canonical URL
Suppliers

Reloaded · same state

/suppliers?tier=A&q=acme&sel=acme-metals&open=billing

Tier ATier B
Acme Metals — billing open
Acme Hardware

Reload key F5 → no loss

03 Restored — F5 lands here, not blank

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 stateURL?Why
Active filter chipsyesShared, bookmarked, restored.
Search queryyesSame — "show me what they're looking at".
Sort column / directionyesAffects what's on screen.
Selected row idyesMaster-detail deep-link.
Expanded sectionsyes"What were you reading?"
Pagination cursoryesBookmark page 4.
Mid-typing input valuenoPer-keystroke writes pollute history.
Hover / focus ringnoTransient — not part of "the view".
Drag preview positionnoLocal-only animation state.
Toast queuenoSession-scoped, dismissed on reload.

Step-by-step build

01

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.

Code-only step

Unknown params → ignored. Missing params → defaults.

Switch to the Code tab to see the snippet.

Step 1 · Schema@corelithzw/react
02

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).

Code-only step

pushState for "open a record" → Back closes the record

Switch to the Code tab to see the snippet.

Step 2 · replace vs push
03

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.

Code-only step

s.q → URL truth (debounced 250ms)

URL → draft sync handles Back/Forward.

Switch to the Code tab to see the snippet.

Step 3 · Debounce
04

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.

Link copied. Paste it to share this exact view.
Step 4 · Share

Final composition

The whole SuppliersList page: URL schema, debounced search, filter chips, selected row, share button.

Suppliers

Share this view
acme
Tier A Tier B Tier C
Acme Metals — billing
Acme Hardware
/suppliers?tier=A&q=acme&sel=acme-metals&open=billing
SuppliersList.tsx@corelithzw/react

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 gets scrollIntoView + 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-pressed updates and screen readers pick it up.
  • Filter and search labels are explicit. aria-label="Search suppliers" on the input; FilterChips groups carry a role="group" with the group name.
  • Clipboard fallback works without HTTPS. If navigator.clipboard isn't available (insecure context on older Android), the recipe falls back to a hidden textarea + execCommand.