History API for SPA Routing
pushState, replaceState, and popstate — client-side routing without reloads, scroll restoration, and server fallback gotchas.
- javascript
- history-api
- spa
- routing
SPAs change the URL without a full page load so the back button and shareable links still work. The browser piece is the History API: pushState, replaceState, and the popstate event. Framework routers wrap this; interviews expect you to know the raw API.
Core methods
// add a history entry (no reload)
history.pushState({ page: 2 }, '', '/products?page=2');
// replace current entry (no new back-stack item)
history.replaceState({ page: 1 }, '', '/products?page=1');
history.state; // { page: 1 } — your object (structured-cloneable)
history.length;
The second argument (title) is ignored by most browsers — pass ''.
window.addEventListener('popstate', (event) => {
// back/forward navigation
renderRoute(location.pathname, event.state);
});
Important: pushState / replaceState do not fire popstate. Only back/forward (and some history.go) do. After pushState, you render yourself.
Minimal router sketch
const routes = {
'/': renderHome,
'/about': renderAbout,
};
function navigate(path, { replace = false } = {}) {
const url = path;
if (replace) history.replaceState({ path }, '', url);
else history.pushState({ path }, '', url);
render(path);
}
function render(path) {
const view = routes[path] ?? renderNotFound;
document.querySelector('#app').replaceChildren(view());
}
window.addEventListener('popstate', (e) => {
render(e.state?.path ?? location.pathname);
});
document.body.addEventListener('click', (e) => {
const a = e.target.closest('a[data-link]');
if (!a) return;
e.preventDefault();
navigate(a.getAttribute('href'));
});
// initial
render(location.pathname);
push vs replace
| Use | Method |
|---|---|
| User went somewhere (back should return) | pushState |
| Fix URL after redirect, query sync, login | replaceState |
| Wizard step that shouldn’t spam history | often replaceState |
Server must cooperate
A direct visit to https://app.com/about hits the server first. The server must serve your SPA shell (index.html) for client routes, or users get 404 on refresh.
# spa fallback idea (conceptually)
/* → /index.html
Hash routing (#/about) avoids server config but is uglier and worse for some SEO/analytics cases.
Scroll and focus
Browsers try to restore scroll on popstate. You can influence:
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
Then set scroll in your render. Manage focus for a11y when the “page” changes.
Limits
- State must be structured-cloneable (no functions, DOM nodes). Size caps exist — keep state small; rehydrate from URL when possible.
- Cross-origin pushState is forbidden.
- Don’t confuse with
location.href =(full navigation).
// full reload navigation
location.assign('/logout');
Interview answer
“SPA routers call history.pushState to change the URL without reload and listen to popstate for back/forward. pushState doesn’t fire popstate, so the router renders immediately after pushing. replaceState updates the current entry. The server must fall back to the app shell for deep links. I keep history.state small and prefer the URL as source of truth.”
Related
Query sync and transitions
function setQuery(params, { replace = true } = {}) {
const url = new URL(location.href);
for (const [k, v] of Object.entries(params)) {
if (v == null) url.searchParams.delete(k);
else url.searchParams.set(k, String(v));
}
const path = url.pathname + url.search + url.hash;
if (replace) history.replaceState(history.state, '', path);
else history.pushState(history.state, '', path);
}
Filters and pagination usually replaceState while typing and pushState when the user clicks “apply” or a page number — product call. Pair with pageshow / persisted for bfcache restores so you rehydrate UI when the user returns via back-forward cache.
Further reading
Related guides
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.
- Array map, filter, reducemap, filter, and reduce as the core transform toolkit — immutability, chaining costs, reduce patterns, and when a plain loop is clearer.
- Arrow Functions Deep DiveLexical this, no arguments object, no construct, concise bodies — when arrows help and when methods and generators need classic functions.
- Async Iteration and for await...ofAsync iterables, for await...of, and streaming data — how async iterators differ from Promise.all and when to use each.