Routing

Turn the URL into state: match a path to a view, link between them, and read the parameters.

19of 20
≈ 11Min read
8Parts

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/12 is 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.

history, and the fallback
// 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

the shape you will meet most often
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} />
the four things you use every day
<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
Never an <a href> for an internal route

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.

state that should survive a reload
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.

the router asks before rendering
{
  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

load a page when it is first visited
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.

Practice