MDN History API - discussion (2022-10-29)
We’re trying to make our back/forward story deterministic, and it’s forcing us to be honest about what state is URL truth vs what state is route doc truth.
Do you ever use pushState/replaceState directly in app code, or do you keep it strictly behind routing primitives? How do you keep navigation changes explainable (so support can tell what happened from a screenshot + URL)? What’s your rule for URL params vs route state when deep links need to reproduce UI posture?
Comments (10)
Back to latestIf you’re calling History API directly, you’re basically writing a router. I only do it when there’s a clear integration boundary.
We avoid direct calls and instead treat navigation as a contract surface (route state + rendered posture).
The History API is the transport; the route doc is the meaning after render.
Related: Routing and Navigation and Navigation Routines (Deep Dive #4).
When we *did* need History API for an embed integration, we wrapped it and rendered a marker so it stayed narratable:
ts
export function replaceUrl(url: string, reason: string) {
history.replaceState({ reason }, '', url);
}
tsx
replaceUrl(`/search?q=${q}`, 'filters');
return <div data-nav="replace" data-reason="filters" />;Counterpoint: sometimes direct replaceState is the least bad option for “update the URL without changing the route”.
But you still need strict rules, or you’ll drift into URL-sync effects that fight back/forward.
Yeah, that’s exactly the case for us (filter UX wants URL updates without a full navigation).
We’re trying to keep the rule “write on intent, never in an effect” and see if it holds.
That rule is solid. The moment you sync in an effect, you’re done.
We also learned that “URL as hint” doesn’t mean “URL is irrelevant”.
Deep links still need a stable entry point, then route state can take over post-render.
If you want deterministic back/forward, you have to pick a contract: URL truth or route doc truth.
A hybrid can work, but only if the route renders the resolved truth so you can tell which one won.
We got rid of a bunch of “back takes you to a weird state” bugs by storing derived evidence once and reading from the doc everywhere.
When each component re-derives from URL, you get disagreement.
MDN has a good explanation of history entries, but the important app-level lesson is: history is a list of contracts.
If the contract isn’t visible, the list becomes scary.
We render a data-route marker for support. It’s not fancy, but it turns “I clicked back and it broke” into “I clicked back to route X with posture Y”.
If you’re touching the History API directly, put a reason string on every call and render the reason somewhere.
It’s the only way it stays reviewable over time.