#Client-side routing
AddedIn a traditional website every link is a request: the browser asks the server for /about, throws the current page away and renders the new HTML. A single-page app (SPA) loads one HTML page and one JavaScript bundle, and from then on JavaScript swaps the content when the URL changes. Nothing is fetched except data and, sometimes, lazily loaded code.
Client-side routing is the glue that makes that feel like a normal website. It has three jobs: change the URL without a request (history.pushState), notice when the URL changes (your own navigations, plus the browser's Back and Forward buttons, which fire popstate), and pick which components to render for the current URL (matching). React Router, TanStack Router and Next.js all do these three things; they differ in how much they add on top.
A good mental model: the URL is a piece of state that lives in the address bar instead of in useState. Like any state, you read it while rendering, you change it in event handlers, and when it changes the UI re-renders. The extra rules are that it's shareable (copy the link), it survives a refresh, and the browser's buttons can change it too.
import { useSyncExternalStore } from "react";
// 1. The "store" is the browser's own URL. Subscribing means listening for changes to it.
function subscribe(onChange) {
window.addEventListener("popstate", onChange); // Back / Forward buttons
window.addEventListener("pushstate", onChange); // our own navigations (see navigate)
return () => {
window.removeEventListener("popstate", onChange);
window.removeEventListener("pushstate", onChange);
};
}
const getPath = () => window.location.pathname;
export function usePath() {
return useSyncExternalStore(subscribe, getPath);
}
// 2. Navigating = change the URL without a request, then tell subscribers.
export function navigate(to) {
window.history.pushState({}, "", to);
window.dispatchEvent(new Event("pushstate")); // pushState itself fires no event
}
// 3. A link is a real <a href> (so it can be opened in a new tab and crawled)
// whose plain left-click is intercepted.
export function MiniLink({ to, children }) {
function handleClick(event) {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey) return; // let the browser open a new tab
event.preventDefault(); // stop the full page load
navigate(to);
}
return (
<a href={to} onClick={handleClick}>
{children}
</a>
);
}
// 4. Matching: pick a component for the current path.
const routes = {
"/": () => <h1>Home</h1>,
"/about": () => <h1>About</h1>,
};
export function MiniApp() {
const path = usePath();
const Page = routes[path] ?? (() => <h1>Not found</h1>);
return (
<>
<nav>
<MiniLink to="/">Home</MiniLink> <MiniLink to="/about">About</MiniLink>
</nav>
<Page />
</>
);
}- On the first render
usePath()readswindow.location.pathname, which is"/", soMiniApplooks uproutes["/"]and renders<h1>Home</h1>.useSyncExternalStore(see useSyncExternalStore) also callssubscribe, so React is now listening forpopstateand our custompushstateevent. - Clicking "About" runs
handleClick. It's a plain left-click with no modifier keys, soevent.preventDefault()cancels the browser's normal behaviour (a full page load of/about). The test checks this: the click event arrives withdefaultPrevented === true. navigate("/about")callshistory.pushState({}, "", "/about"). The address bar changes and a history entry is added, but no request is made and no event fires —pushStateis silent by design. That's why the next line dispatches apushstateevent of our own.- The event reaches the listener
useSyncExternalStoreregistered. React callsgetPath()again, sees"/about"instead of"/", and re-rendersMiniApp, which now renders<h1>About</h1>. The test confirmslocation.pathnameis"/about"and the heading changed. - The Back button (
history.back()in the test) changes the URL back to"/"and the browser firespopstate— the second thing we subscribed to — so the page returns to Home.history.lengthis2: the start page plus the/aboutentry we pushed. - Ctrl/Cmd-click and middle-click are left alone, so the browser opens the real
hrefin a new tab. That's why links must be real<a href>elements, not<div onClick>: new tabs, "copy link", screen readers and crawlers all rely on thehref.
A real router adds what this toy lacks: dynamic segments (/posts/:id), nested layouts, ranking when several patterns match, relative links, search params, scroll restoration, data loading and code splitting. But underneath, React Router's <BrowserRouter> does exactly this: it listens to popstate, calls pushState on navigation and re-renders with the new location.