What a client router does
A single-page app serves one HTML file. The router reads the URL, decides which component to render, and changes the URL when you navigate — without a request to the server.
- The URL is state. Bookmarking, sharing, reloading and the back button all have to keep working.
- A link that does a full page load throws away your JavaScript state and redownloads everything. A router link does not.
- The server still has to answer every path with the same HTML, or a reload of
/doors/12is a 404.
The two mechanisms
The History API is the default for a real deployment. A hash router is the right answer on static hosting you cannot configure, inside an embedded frame, or in a sandbox — the mechanics of matching and rendering are identical either way.
// clean URLs — needs the server configured to serve index.html for every path
history.pushState({}, "", "/doors/12");
window.addEventListener("popstate", render); // back and forward
// hash URLs — everything after # never reaches the server, so any host works
location.hash = "#/doors/12";
window.addEventListener("hashchange", render);
React Router, in one screen
import { createBrowserRouter, RouterProvider, Outlet, Link, useParams } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <Layout />, // renders <Outlet /> where children go
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Home /> },
{ path: "doors", element: <Doors /> },
{ path: "doors/:id", element: <Door /> },
{ path: "*", element: <NotFound /> },
],
},
]);
<RouterProvider router={router} />
Links, parameters and navigating
<Link to="/doors">Doors</Link>
<NavLink to="/doors" className={({ isActive }) => isActive ? "on" : ""}>Doors</NavLink>
const { id } = useParams(); // "/doors/:id" → { id: "12" }
const [params, setParams] = useSearchParams(); // "?q=oak" → params.get("q")
const navigate = useNavigate();
navigate("/doors"); // after saving
navigate(-1); // back
navigate("/doors", { replace: true }); // no new history entry — for redirects
It reloads the whole application. Use the router’s link, which intercepts the click and calls the history API instead.
A search box belongs in the URL
Filters, sort order, the open tab, the page number: putting them in the query string makes the screen shareable and the back button meaningful. replace: true keeps every keystroke out of the history.
const [params, setParams] = useSearchParams();
const query = params.get("q") ?? "";
<input value={query} onChange={(e) => setParams({ q: e.target.value }, { replace: true })} />
Loading data per route
Route loaders start the request as the navigation begins, instead of after the component mounts. That removes a whole class of waterfall, and it is the same idea Next.js and Remix build on.
{
path: "doors/:id",
loader: ({ params }) => fetchDoor(params.id),
element: <Door />,
}
function Door() {
const door = useLoaderData(); // already here: no loading state inside the component
…
}
Code splitting by route
const Admin = React.lazy(() => import("./Admin.jsx"));
<React.Suspense fallback={<Spinner />}>
<Admin />
</React.Suspense>
Things that get forgotten
- A
*route with a real 404 page, not a blank screen. - Focus: after a route change, move focus to the new
<h1>— a screen-reader user is otherwise left where they were. - Scroll: the browser restores scroll on a real navigation; a client router does not, so scroll to the top yourself.
- A redirect after an action uses
replace, so Back does not land on the form again.