MDN History API - discussion (2023-01-18)
MDN's History API docs are still the best "what actually happens" reference when an app's routing behavior feels off. I'm curious how people apply push/replace/pop semantics in route-first React apps where the URL is a map, route state is the contract, and motion needs to be explainable via evidence.
How do you decide between pushState and replaceState for component-router motion (overlays, tabs, selection)? Do you persist route state into the URL, or treat URL params as a hint that seeds state once? What evidence do you render so "back" behavior is debuggable without watching a video?
Comments (14)
Back to latestReplace is underrated.
If the user is just changing posture (toggle, filter), replace can keep history meaningful instead of a thousand micro-steps.
We treat push vs replace as part of the route contract: navigation intent chooses it explicitly.
ts
type NavIntent = { type: 'openOverlay'; overlay: 'share' | 'search'; mode: 'push' | 'replace' };
function applyNav(intent: NavIntent) {
const url = new URL(location.href);
url.searchParams.set('overlay', intent.overlay);
if (intent.mode === 'push') history.pushState({}, '', url);
else history.replaceState({}, '', url);
}
Then we render data-overlay evidence so back/forward is explainable.
Counterpoint: replaceState can surprise users if it hides steps they expected to undo.
We got complaints when a filter change couldn't be backed out because we replaced instead of pushing.
Yep, it's a UX choice. We only use replace for ephemeral posture (tab focus, density, panel) and push for user-meaningful changes.
The hard part is agreeing what counts as meaningful.
The best evidence we added was a route ledger that stores {from,to,reason}.
When "back" behaves weird, you can look at the ledger and see what the app thought it was doing.
We treat URL params as a hint: parse once, then route state becomes truth, then we re-serialize the parts that are shareable.
That kept us from sprinkling new URLSearchParams() across the codebase.
A misunderstanding I had early on: I thought popstate should "restore" everything automatically.
In practice, the app needs a route contract that decides what gets restored and what gets re-derived.
We used History API to keep selection shareable (e.g. selectedId=...) but kept volatile state (scroll) local.
If you try to serialize everything, the URL turns into a state dump.
Code snippet: a tiny popstate handler that writes evidence instead of silently mutating state:
ts
window.addEventListener('popstate', () => {
route.write('lastNav', { type: 'pop', at: Date.now(), href: location.href });
});
If you can see lastNav, you stop guessing why the UI changed.
Docs tie-in: a lot of this is just routing fundamentals + evidence.
Related: Routing and Navigation and The App Router Mindset.
We also stopped calling things "back button bugs" and started calling them "history contract bugs".
Once it's a contract, you can test it and reason about it.
Counterpoint: ledgers are great until they get noisy.
We only record navigations that change identity (route, selected id, overlay open/close), not every keystroke.
Same. We treat the ledger like audit logs: record the decisions users care about.
The evidence needs to stay readable.
History API is also where focus/scroll bugs hide.
We ended up rendering data-restored=true when a pop restoration happened so the UI could adapt intentionally.
If you do replace/push inconsistently across the app, back/forward becomes impossible to predict.
Pick a rule and make it explicit, even if it's a simple one.
MDN is great for the primitive details, but the hard part is deciding which motions deserve a history entry. That's app design.