Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I6
17 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I6 · Part 2 · Intermediate

React Router

The URL is state your users can bookmark, share and go back to.

6 min read13 sections1 exercise
17 of 57

In this chapter

  • The shape
  • Three modes, one library
  • Links, not anchors
  • Reading the URL
  • Loaders: fetching before the render

and 8 more

about 6 min
13On this page
  1. The shape
  2. Three modes, one library
  3. Links, not anchors
  4. Reading the URL
  5. Loaders: fetching before the render
  6. Actions and forms
  7. Mutations that should not navigate
  8. Framework mode: routes as modules, types generated
  9. Protecting routes
  10. Lazy routes
  11. Scroll, and the thing everyone forgets
  12. The other router you will meet: TanStack Router
  13. Which routing you are actually using

The shape

import { createBrowserRouter, RouterProvider } from "react-router";

const router = createBrowserRouter([
  {
    path: "/",
    element: <Layout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <Home /> },
      { path: "topics/:topicId", element: <Topic /> },
      { path: "*", element: <NotFound /> },
    ],
  },
]);

<RouterProvider router={router} />

Routes nest, and a parent renders its matched child through <Outlet />. That is how a layout stays mounted while the page inside it changes — the sidebar does not remount when you navigate.

function Layout() {
  return (
    <div>
      <Sidebar />
      <main><Outlet /></main>      // the matched child goes here
    </div>
  );
}

Imports come from react-router. Version 7 folded react-router-dom into it, and version 8 (June 2026) removed the old package entirely, so a tutorial importing from react-router-dom is at least one major version old.

Three modes, one library

Since version 7 React Router is really three products stacked on each other, and the first thing to establish in any codebase is which one it uses.

ModeYou enter it withIt adds
Declarative<BrowserRouter>URL matching, Link, useNavigate, active states. Data is your problem.
DatacreateBrowserRouter + <RouterProvider>Loaders, actions, pending states, useFetcher — most of this page
FrameworkThe Vite plugin and a routes.ts fileGenerated route types, SSR or static or SPA rendering, automatic code splitting

The modes are a ladder, not rivals. Everything you learn in data mode — loaders, actions, revalidation — is exactly what framework mode uses, with the wiring and the types generated for you. Remix v2 became React Router 7's framework mode, which is why the two share every idea.

Links, not anchors

<Link to="/topics/react">React</Link>
<NavLink to="/topics/react" className={({ isActive }) => isActive ? "on" : ""}>

A plain <a> reloads the whole application — new document, new bundle, all state lost. Link renders a real anchor (so middle-click, right-click and Cmd-click still work) and intercepts the plain click to change history instead. NavLink is the same thing with an isActive flag for styling the current item.

Reading the URL

const { topicId } = useParams();                    // /topics/:topicId
const [params, setParams] = useSearchParams();      // ?level=beginner
const location = useLocation();                     // pathname, search, hash, state
const navigate = useNavigate();

navigate("/topics/react");
navigate(-1);                                       // back
navigate("/login", { replace: true });              // no history entry
Put filters in the URL, not in useState

A filter, a sort order, a search query, an open tab — all of these are state the user expects to survive a refresh, a shared link and the back button. useSearchParams gives you all three for free, and useState gives you none of them.

const [params, setParams] = useSearchParams();
const level = params.get("level") ?? "beginner";

<select value={level} onChange={(e) => setParams({ level: e.target.value })}>

Two details bite. Everything in the query string is a string, so params.get("page") is "2", not 2, and can be null or garbage someone typed. And setParams({ level }) replaces the whole query string; to change one key and keep the rest, use the function form: setParams((p) => { p.set("level", v); return p; }).

Loaders: fetching before the render

{
  path: "topics/:topicId",
  element: <Topic />,
  loader: async ({ params }) => {
    const res = await fetch("/api/topics/" + params.topicId);
    if (!res.ok) throw new Response("Not found", { status: 404 });
    return res.json();
  },
}

function Topic() {
  const topic = useLoaderData();     // already there on first render
}

This removes an entire category of bug. There is no loading state inside the component, no effect, no race between the old route's request and the new one's — the router waits, cancels a superseded navigation, and only then renders. Throwing a Response hands control to the nearest errorElement.

The old pattern — render, then fetch in an effect — is a render-fetch waterfall: each nested component discovers what it needs only after its parent has rendered. Loaders run in parallel for the whole matched tree.

Actions and forms

{ path: "new", element: <NewTopic />, action: async ({ request }) => {
    const data = Object.fromEntries(await request.formData());
    await createTopic(data);
    return redirect("/topics");
  }
}

function NewTopic() {
  const nav = useNavigation();
  return (
    <Form method="post">
      <input name="title" />
      <button disabled={nav.state === "submitting"}>Save</button>
    </Form>
  );
}

The router's Form submits to the action, waits, then revalidates every loader on the page so the list is fresh. No manual refetch, no invalidation logic.

Mutations that should not navigate

function LikeButton({ id, liked }) {
  const fetcher = useFetcher();
  const optimistic = fetcher.formData
    ? fetcher.formData.get("liked") === "true"
    : liked;

  return (
    <fetcher.Form method="post" action={"/posts/" + id + "/like"}>
      <button name="liked" value={String(!optimistic)}>
        {optimistic ? "♥" : "♡"}
      </button>
    </fetcher.Form>
  );
}

A like button, a checkbox in a list, an inline rename: none of these should change the URL or add a history entry. useFetcher calls the same loaders and actions without navigating, and each fetcher has its own pending state, so twenty rows can each be saving independently. Reading fetcher.formData while it is in flight gives you optimistic UI with no extra state.

Framework mode: routes as modules, types generated

// app/routes.ts
import { index, route } from "@react-router/dev/routes";

export default [
  index("./home.tsx"),
  route("products/:pid", "./product.tsx"),
];
// app/product.tsx
import type { Route } from "./+types/product";

export async function loader({ params }: Route.LoaderArgs) {
  return { product: await getProduct(params.pid) };   // params.pid is typed
}

export default function Product({ loaderData }: Route.ComponentProps) {
  return <h1>{loaderData.product.name}</h1>;           // so is loaderData
}

Each route file exports named pieces — loader, action, a default component, ErrorBoundary, meta — and the tooling writes a +types file beside it. The payoff is that the loader's return type flows into the component without a cast, and a typo in params.pid is a compile error. In framework mode loader runs on the server; clientLoader is its browser-side counterpart, for data that only the client can reach.

Protecting routes

function RequireAuth({ children }) {
  const { user } = useAuth();
  const location = useLocation();

  if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
  return children;
}

replace matters: without it, pressing back from the login page returns to the protected route, which bounces to login again — and the user is trapped. Carrying from in location state lets you send them where they were going after they sign in.

A client-side guard is a UX affordance, not security. The API still has to authorise every request; hiding a route hides a link, not the data. In data and framework modes, checking in a loader or middleware is better still: the protected page's code and data never start loading. Middleware became a default, stable feature in version 8.

Lazy routes

const Settings = lazy(() => import("./Settings"));

{ path: "settings", element: (
    <Suspense fallback={<Spinner />}><Settings /></Suspense>
  )
}

Route boundaries are the natural place to split a bundle: a user who never opens Settings never downloads it. Suspense and code splitting goes further. Framework mode does this split for every route automatically.

Scroll, and the thing everyone forgets

Client-side navigation does not reset scroll. Navigate from the bottom of a long list into a detail page and you land halfway down it. The data router restores scroll when you render <ScrollRestoration />; with the declarative router you add it yourself:

useEffect(() => { window.scrollTo(0, 0); }, [location.pathname]);

The other router you will meet: TanStack Router

const productSearch = z.object({
  page: z.number().catch(1),
  sort: z.enum(["newest", "price"]).catch("newest"),
});

export const Route = createFileRoute("/shop/products")({
  validateSearch: productSearch,
});

function Products() {
  const { page, sort } = Route.useSearch();        // page: number, not string
  const navigate = useNavigate({ from: Route.fullPath });
  navigate({ search: (prev) => ({ ...prev, page: prev.page + 1 }) });
}

TanStack Router's pitch is type safety end to end, and its sharpest feature is the search string. A schema validates and parses it once, at the route, so every component reads a typed number rather than a string it has to trust, and a bad value in a shared link falls back to a default instead of crashing the page. It is common in Vite single-page apps that want that guarantee without a server framework.

Which routing you are actually using

SetupRoutingNotes
Vite + ReactReact Router data mode, or TanStack RouterEverything on this page
React Router framework moderoutes.ts and route modulesWhere Remix went; loaders run on the server
Next.js App RouterFile-system routingFolders are routes; useRouter, usePathname, useSearchParams from next/navigation

The concepts transfer completely: nested layouts, URL params, loading before render, and the URL as shared state. Only the imports change.

Interview answer, one sentence

"The router keeps the URL and the UI in sync, and the URL should hold any state a user would expect to bookmark or go back to — filters, tabs, queries. Loaders matter because they fetch before rendering, which removes the render-fetch waterfall and the race between two navigations, and framework mode or TanStack Router adds generated types so params and loader data are checked rather than trusted."

01Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsFilters from the query stringintermediate
All 66 for this level →
I6

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I5Previous · I5Effects in depth
  2. I6you are here
  3. Next · I7State architectureMost React problems are state in the wrong place, in the wrong shape, or duplicated.4 min read

01On this page

13 sections · 0%

  1. 01The shape
  2. 02Three modes, one library
  3. 03Links, not anchors
  4. 04Reading the URL
  5. 05Loaders: fetching before the render
  6. 06Actions and forms
  7. 07Mutations that should not navigate
  8. 08Framework mode: routes as modules, types generated
  9. 09Protecting routes
  10. 10Lazy routes
  11. 11Scroll, and the thing everyone forgets
  12. 12The other router you will meet: TanStack Router
  13. 13Which routing you are actually using

[] chapters · t top