I6 · Part 2 · Intermediate
React Router
The URL is state your users can bookmark, share and go back to.
13On this page
- The shape
- Three modes, one library
- Links, not anchors
- Reading the URL
- Loaders: fetching before the render
- Actions and forms
- Mutations that should not navigate
- Framework mode: routes as modules, types generated
- Protecting routes
- Lazy routes
- Scroll, and the thing everyone forgets
- The other router you will meet: TanStack Router
- 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.
| Mode | You enter it with | It adds |
|---|---|---|
| Declarative | <BrowserRouter> | URL matching, Link, useNavigate, active states. Data is your problem. |
| Data | createBrowserRouter + <RouterProvider> | Loaders, actions, pending states, useFetcher — most of this page |
| Framework | The Vite plugin and a routes.ts file | Generated 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
| Setup | Routing | Notes |
|---|---|---|
| Vite + React | React Router data mode, or TanStack Router | Everything on this page |
| React Router framework mode | routes.ts and route modules | Where Remix went; loaders run on the server |
| Next.js App Router | File-system routing | Folders 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."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series