Routing in React
How client-side routing works: the URL as state, route matching, links, nested layouts, and loading data per route, built by hand and then with React Router.
- 01Explain what a router doesKeep the address as state, match it to a route, and show that route's page without loading a new one from the server.
- 02Build pages with React RouterDefine routes with params, nested layouts, links and menus, and read the address and params with its hooks.
- 03Load data at the routeStart a page's data with a loader, show its errors, and show that the next page is loading while the old one stays.
Three pages and an address that never changes
A shop has pages: a home page, a list of teas, a cart. In React you could keep the current page in state and show the matching component. Here is that shop. Click its three buttons.
import { useState } from "react"; export default function App() { const [page, setPage] = useState("home"); return ( <> <nav> <button onClick={() => setPage("home")}>Home</button> <button onClick={() => setPage("teas")}>Teas</button> <button onClick={() => setPage("cart")}>Cart</button> </nav> {page === "home" && <h2>Welcome to the tea shop</h2>} {page === "teas" && <h2>All teas</h2>} {page === "cart" && <h2>Your cart</h2>} </> );}Line 4 keeps the page's name in state, starting with "home". Lines 9 to 11 set it, and lines 13 to 15 show the heading for the page that's picked. It works while you click. Now go to the cart and press Run again, which starts the result over, the way a refresh starts a page over: you're back on the home page.
On a real site, keeping the page in state breaks things people expect from a web page:
- The address never changes. It says
shop.example/on every page, so you can't bookmark the cart or send a friend the link to one tea. - Refresh loses your place. State starts over, so a refresh always shows the home page.
- Back leaves the shop. The browser's history has one entry for the whole shop, so Back goes to the site you came from.
A router fixes this by making the address the thing that decides the page. The URL is the full address, like https://shop.example/teas/mint; the part after the site's name, /teas/mint, is its path. A route is a rule that pairs a path pattern with the component to show for it. On every click, a router changes the address, finds the route that fits it, and shows that route's component. React doesn't include a router; it comes from a library such as React Router, or from a framework built on one.
/teas/mint, and tries its routes. /teas/:teaId fits, with teaId set to "mint", so its page shows Mint tea. No new page comes from the server.In a big shopping centre, every shop has an address. You can write it on a card and give it to a friend, and they end up at the same shop. The directory board reads an address and points you to the right shop, and you walk from shop to shop without ever leaving the building.
- In real life: A shop's address: level 2, unit 14
- In React: A path:
/teas/mint - In real life: The directory board that matches an address to a shop
- In React: The router, matching the path to a route
- In real life: "Unit 14 on any level is a tea shop": one rule for many units
- In React: A route pattern with a param:
/teas/:teaId - In real life: Writing the address down, so a friend finds the same shop
- In React: Sharing or bookmarking the URL
- In real life: Walking to the next shop without leaving the building
- In React: Changing the page without loading a new one from the server
Client-side routing makes the URL the state that decides what the page shows. The router reads the address, matches it to a route, and renders that route's components. When you follow a link, it changes the address and the page without asking the server for a new page.
The address is state that lives outside React
The address belongs to the browser, not to a component. Any part of the page can change it, and Back and Forward change it without asking React. That makes it outside data, the kind the Subscribing to external stores lesson reads with useSyncExternalStore. So the first piece of a router is a small store that holds the address.
The live results on this page have no address bar of their own, so the examples keep a pretend browser history in a store and draw an address bar on the page. Here is the shop again, on a tiny router written for this lesson. It has four tabs: App.jsx, router.jsx, AddressBar.jsx, and styles.css.
import AddressBar from "./AddressBar.jsx";import { Link, usePath } from "./router.jsx"; export default function App() { const path = usePath(); return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas">Teas</Link> <Link to="/cart">Cart</Link> </nav> {path === "/" && <h2>Welcome to the tea shop</h2>} {path === "/teas" && <h2>All teas</h2>} {path === "/cart" && <h2>Your cart</h2>} </> );}Click Teas, then Cart. The address changes to shop.example/teas, then shop.example/cart, and the list under it, the history, grows to three entries. Press Back: the address says /teas again, and the page follows. In App.jsx, line 5 reads the path with usePath(), and lines 15 to 17 show a heading for each path, as the state version did with its page name. Lines 11 to 13 are links instead of buttons. Now open the router.jsx tab:
- Line 4 is the history: a list of
entries, the addresses you visited, and theindexof the one you're on. Line 5 keeps the components that are listening. updateon lines 7 to 10 replaces the history with a new object and tells every listener. A new object, never a change to the old one, so React can tell it changed.navigateon lines 12 to 15 goes to a new address. Line 13 keeps the entries up to the current one and adds the new path; line 14 points the index at it.goon lines 17 to 20 moves the index:go(-1)is Back andgo(1)is Forward. Line 19 does nothing past either end.useHistoryon lines 27 to 29 reads the store withuseSyncExternalStore:subscribe(lines 22 to 25) adds a listener, and the snapshot is the history object.usePathon lines 31 to 34 returns the current entry.Linkon lines 36 to 42 draws a real<a href>. Its click handler stops the browser from loading the address from the server (event.preventDefault()on line 38) and callsnavigateinstead.
AddressBar.jsx draws the address and the history list from useHistory(), and its buttons call go(-1) and go(1). Now, still on /teas, click Home. The history reads /, /teas, /: the /cart that was ahead of you is gone, and Forward is greyed out. Line 13 dropped it before adding the new address, as browsers do.
/teas, then /cart, then pressed Back. The index moved; /cart is still ahead, for Forward. Right: a new navigate drops every entry ahead of the index, then adds the new address.On a real page, the browser keeps the history, and only the store's three functions change. The History & navigation lesson covers pushState and popstate:
export function navigate(path) {
window.history.pushState(null, "", path);
listeners.forEach((listener) => listener());
}
// Back and Forward change the address, then fire popstate.
window.addEventListener("popstate", () => {
listeners.forEach((listener) => listener());
});
export function usePath() {
return useSyncExternalStore(subscribe, () => window.location.pathname);
}Line 2 changes the address in the browser's address bar and adds a history entry, without loading a page. Lines 7 to 9 listen for popstate, which the browser fires after Back or Forward. Line 12 reads the path from window.location.pathname. It doesn't run in the results on this page: they have no address bar to change. Real routers also let a click with Ctrl, Cmd, or the middle button through, so "open in a new tab" still works.
Matching an address to a route
Checking path === "/teas" for each page doesn't scale: a shop with 200 teas would need 200 checks. A route pattern can have a param, a part that starts with : and fits any value. /teas/:teaId fits /teas/green and /teas/mint, and gives you teaId. Step 2 of the tiny router adds two functions to the bottom of router.jsx, lines 44 to 65:
import AddressBar from "./AddressBar.jsx";import { Link, Routes } from "./router.jsx"; const teas = { green: "Green tea", mint: "Mint tea" }; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaList() { return ( <ul> <li><Link to="/teas/green">Green tea</Link></li> <li><Link to="/teas/mint">Mint tea</Link></li> <li><Link to="/teas/oolong">Oolong</Link></li> </ul> );} function TeaPage({ params }) { return <h2>{teas[params.teaId] ?? `We don't sell "${params.teaId}"`}</h2>;} function NotFound() { return <h2>Page not found</h2>;} const routes = [ { path: "/", Component: Home }, { path: "/teas", Component: TeaList }, { path: "/teas/:teaId", Component: TeaPage }, { path: "*", Component: NotFound },]; export default function App() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas">Teas</Link> <Link to="/gifts">Gifts</Link> </nav> <Routes routes={routes} /> </> );}In App.jsx, lines 28 to 33 list the routes, each a path pattern and a component. * on line 32 fits any address, for a "not found" page. Line 44 hands the list to Routes. TeaPage on lines 20 to 22 gets the params as a prop and looks the tea up with params.teaId.
In router.jsx, matchPath (lines 45 to 55) splits the pattern and the path at each / and drops the empty parts. If they have a different number of parts, line 48 returns null: no fit. Then it compares part by part: a part that starts with : saves the address's part under that name (line 51), and any other part must be the same (line 52). Routes (lines 58 to 65) tries the routes in order and draws the first one that fits, with its params.
Click Teas, then Mint tea: /teas/mint shows Mint tea. Click Teas and Oolong: the route fits, but the shop has no oolong, so TeaPage says We don't sell "oolong". Gifts fits no route but *, so it says Page not found. Try any address in the playground, with the same matchPath:
// The params when the path fits the pattern, or null when it doesn't.export function matchPath(pattern, path) { const want = pattern.split("/").filter(Boolean); const got = path.split("/").filter(Boolean); if (want.length !== got.length) return null; const params = {}; for (let i = 0; i < want.length; i += 1) { if (want[i].startsWith(":")) params[want[i].slice(1)] = got[i]; else if (want[i] !== got[i]) return null; } return params;}shows TeaPage/0 parts in the pattern, 2 in the address./teas1 part in the pattern, 2 in the address./teas/:teaIdFits. Params: { teaId: "mint" }.
For /teas/mint, Routes tried 3 routes and stopped at /teas/:teaId, so the page is TeaPage.
Try /teas/mint/reviews: /teas/:teaId has two parts and the address three, so only * fits. A param fits exactly one part, never a slash.
Links, and navigating from your code
The tiny Link draws an <a href>, not a <button>, on purpose. A link is something people and browsers already understand: hovering it shows the address, it can open in a new tab, it can be copied, and screen readers announce it as a link. The router only takes over a plain click, to change the page without a page load.
Sometimes your code decides where to go, not a link. After an order is placed, the shop should show a thank-you page. Call navigate in the event handler:
import AddressBar from "./AddressBar.jsx";import { Link, Routes, navigate } from "./router.jsx"; function Home() { return <h2>Welcome to the tea shop</h2>;} function Cart() { function placeOrder() { // Send the order to the server here, then: navigate("/thanks"); } return ( <> <h2>Your cart: 2 teas</h2> <button onClick={placeOrder}>Place order</button> </> );} function Thanks() { return <h2>Thanks for your order!</h2>;} const routes = [ { path: "/", Component: Home }, { path: "/cart", Component: Cart }, { path: "/thanks", Component: Thanks },]; export default function App() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/cart">Cart</Link> </nav> <Routes routes={routes} /> </> );}Line 2 imports navigate with the rest. placeOrder on lines 9 to 12 is the click handler for the button on line 16; a real shop would send the order to its server first, then line 11 goes to /thanks. Click Cart, then Place order: the address says /thanks, and the history is /, /cart, /thanks.
Now press Back. You're on the cart again, with its Place order button, for an order that's already placed. For moments like this, routers can replace the current entry instead of adding one, so Back skips it. React Router writes it navigate("/thanks", { replace: true }); exercise 5 adds it to the tiny router. Use a link for anything the person picks, and navigate for a page your code moves them to after something happens.
The same shop with React Router
The tiny router has the parts every router has. A real one adds what a shop needs on top: layouts, data loading, errors, pending pages, and years of fixes. React Router is the most widely used router for React. This lesson uses version 8.5.0, the latest when it was checked in October 2026; React Router 7 code looks the same for everything shown here. In a project, you install it:
npm install react-routerHere is the shop from the matching section on React Router. Its result works the same way: try Teas, Mint tea, Oolong, and Gifts.
import { createMemoryRouter, Link, Outlet, RouterProvider, useParams } from "react-router@8.5.0?dev&deps=react@19.3.1,react-dom@19.3.1";import AddressBar from "./AddressBar.jsx"; const teas = { green: "Green tea", mint: "Mint tea" }; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaList() { return ( <ul> <li><Link to="/teas/green">Green tea</Link></li> <li><Link to="/teas/mint">Mint tea</Link></li> <li><Link to="/teas/oolong">Oolong</Link></li> </ul> );} function TeaPage() { const { teaId } = useParams(); return <h2>{teas[teaId] ?? `We don't sell "${teaId}"`}</h2>;} function NotFound() { return <h2>Page not found</h2>;} function Layout() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas">Teas</Link> <Link to="/gifts">Gifts</Link> </nav> <Outlet /> </> );} const router = createMemoryRouter([ { path: "/", Component: Layout, children: [ { index: true, Component: Home }, { path: "teas", Component: TeaList }, { path: "teas/:teaId", Component: TeaPage }, { path: "*", Component: NotFound }, ], },]); export default function App() { return <RouterProvider router={router} />;}Lines 43 to 54 make the router with createMemoryRouter, from a list of route objects, and line 57 shows it with RouterProvider. A memory router keeps its history in memory, like the tiny router does, which is what these results need. Route objects can hold other routes: the route on lines 44 to 53 draws Layout for every page, and its children are the pages. { index: true } on line 48 is the page for the parent's own path, /, and the child paths on lines 49 to 51 are written without the parent's /.
Layout on lines 29 to 41 draws the address bar and the menu, then <Outlet /> on line 38, the place where the matching child route's page goes. TeaPage reads its param with useParams() on line 21, so no prop is needed: the router keeps the match in a context, and any component inside the route can read it. The AddressBar.jsx tab reads the address with useLocation() and goes back with navigate(-1) from useNavigate().
Line 1 is the one odd line: the live result loads React Router 8.5.0 from a CDN (a server for packages), with ?dev&deps=react@19.3.1,react-dom@19.3.1 so it uses the same React as the page. In a project you write from "react-router", and a real site keeps its history in the browser with createBrowserRouter:
import { createRoot } from "react-dom/client";
import { createBrowserRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import { routes } from "./routes.jsx";
const router = createBrowserRouter(routes);
createRoot(document.getElementById("root")).render(
<RouterProvider router={router} />,
);Line 2 imports createBrowserRouter, which uses pushState and popstate as the browser version of the tiny store did. Line 3 imports RouterProvider from react-router/dom, the version React Router recommends in the browser. Line 6 makes the router once, outside any component, so it isn't made again on every render.
One difference matters: React Router doesn't take the first route that fits. It ranks the routes and picks the most specific one, so /teas beats * wherever you write them. How to read the table: each row is one job of a router, done in the tiny router and in React Router.
| The job | The tiny router | React Router |
|---|---|---|
| Keep the address and the history | history in router.jsx, a store read with useSyncExternalStore. | createMemoryRouter here, createBrowserRouter on a real site. |
| Read the address | usePath(). | useLocation(), which also has the search part. |
| Change the address on a click | <Link to>. | <Link to>, and <NavLink> for menus that mark the current page. |
| Change the address from code | navigate(path), and go(-1) for Back. | useNavigate(), then navigate(path) or navigate(-1). |
| Find the page for the address | Routes tries each route in order and shows the first that fits. | The route objects. It ranks them and shows the most specific one that fits. |
| Read the params | The params prop. | useParams(), in any component inside the route. |
| Share a layout between pages | Not built. | Child routes, and <Outlet /> where they go. |
| Load data, and show errors and pending pages | Not built. | loader, useLoaderData(), ErrorBoundary, and useNavigation(). |
React Router also has two other ways to use it. In declarative mode you write routes as <Routes> and <Route> elements inside a <BrowserRouter>, with no loaders. In framework mode a build plugin finds your routes, splits the code per page, and can render pages on the server. This lesson uses the middle one, data mode, which has loaders without the build plugin.
TanStack Router (version 1.170.46 in October 2026) is the other common choice. It does the same jobs, and is built around TypeScript: it knows every route, so a link to a route that doesn't exist, or a missing param, is a type error. It also checks search params against a schema and caches loader data. Here is a route in it, as a sketch:
import { createRoute } from "@tanstack/react-router";
const teaRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/teas/$teaId",
loader: ({ params }) => fetchTea(params.teaId),
component: TeaPage,
});Line 5 writes the param as $teaId instead of :teaId, and line 6 is a loader, like React Router's. The ideas in this lesson carry over to either one.
Nested routes share a layout
Most pages of a site share parts: a header, a menu, a search box. An account area adds its own tabs. With nested routes, each part is drawn once, by the route that owns it. Type in the search box, then click around:
import { createMemoryRouter, NavLink, Outlet, RouterProvider } from "react-router@8.5.0?dev&deps=react@19.3.1,react-dom@19.3.1";import { useState } from "react";import AddressBar from "./AddressBar.jsx"; function Layout() { const [search, setSearch] = useState(""); return ( <> <AddressBar /> <nav> <NavLink to="/" end>Home</NavLink> <NavLink to="/account">Account</NavLink> <input placeholder="Search teas" value={search} onChange={(event) => setSearch(event.target.value)} /> </nav> <Outlet /> </> );} function Home() { return <h2>Welcome to the tea shop</h2>;} function AccountLayout() { return ( <section> <h2>Your account</h2> <nav> <NavLink to="/account" end>Orders</NavLink> <NavLink to="/account/profile">Profile</NavLink> </nav> <Outlet /> </section> );} function Orders() { return <p>2 orders: green tea, chai</p>;} function Profile() { return <p>Name: Asha</p>;} const router = createMemoryRouter([ { path: "/", Component: Layout, children: [ { index: true, Component: Home }, { path: "account", Component: AccountLayout, children: [ { index: true, Component: Orders }, { path: "profile", Component: Profile }, ], }, ], },]); export default function App() { return <RouterProvider router={router} />;}The route on lines 46 to 60 draws Layout; inside it, the account route on lines 51 to 58 draws AccountLayout, and inside that, the index route on line 55 draws Orders at /account and line 56 draws Profile at /account/profile. Each layout has an <Outlet /> (lines 15 and 32) for the level below it.
/account/profile fits three routes, one inside the other. Each route's component draws its own part and an <Outlet /> where the next one goes. Going to /account only swaps Profile for Orders.Type chai in the search box, click Account, then Profile. The text stays. Both addresses fit the same Layout route, so React keeps the same Layout and only swaps what's in its Outlet: its state on line 6 lives on, as the Preserving and resetting state lesson explains. Nothing is reloaded; only the part that changed renders anew.
The menus use NavLink, a Link that knows whether it points at the current page. It adds the class active (bold, from styles.css) and aria-current="page", which screen readers announce. The end on lines 11 and 29 means "only the exact address": without it, /account would also count as active on /account/profile, because that address starts with it.
Loading a page's data at the route
A tea page needs the tea's data from a server. In the Fetching data lesson, a component asked for its data in an effect, after it rendered. A router knows sooner: the moment you click, the address says which routes will show. So React Router lets each route have a loader, a function that gets the route's data before its page renders. The api.js tab is a pretend server that answers after 0.8 seconds.
import { createMemoryRouter, Link, Outlet, RouterProvider, useLoaderData, useRouteError } from "react-router@8.5.0?dev&deps=react@19.3.1,react-dom@19.3.1";import AddressBar from "./AddressBar.jsx";import { fetchTea } from "./api.js"; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaPage() { const tea = useLoaderData(); return ( <> <h2>{tea.name}</h2> <p>${tea.price}, {tea.stock} left</p> </> );} function TeaError() { const error = useRouteError(); return <p>Sorry: {error.message}</p>;} function Layout() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas/green">Green tea</Link> <Link to="/teas/mint">Mint tea</Link> <Link to="/teas/oolong">Oolong</Link> </nav> <Outlet /> </> );} const router = createMemoryRouter([ { path: "/", Component: Layout, children: [ { index: true, Component: Home }, { path: "teas/:teaId", Component: TeaPage, loader: ({ params }) => fetchTea(params.teaId), ErrorBoundary: TeaError, }, ], },]); export default function App() { return <RouterProvider router={router} />;}Line 48 gives the tea route a loader: it gets the params and returns fetchTea(params.teaId), a promise. The router waits for it, then renders TeaPage, which reads the answer with useLoaderData() on line 10. There's no effect, no Loading… state, and no null check: when TeaPage renders, its data is there. If the loader throws or its promise fails, the router shows the route's ErrorBoundary (line 49) in the page's place, and TeaError reads the error with useRouteError() on line 20. It works like the error boundaries you know, for data.
Click Green tea with the console open: Request 1: tea green appears right away, and the page changes 0.8 seconds later. Click Oolong: the loader's promise fails, and the page says Sorry: We don't sell "oolong". while the menu stays. Here is the same tea page with an effect, from the Fetching data lesson's pattern, to compare:
function TeaPage() {
const { teaId } = useParams();
const [tea, setTea] = useState(null);
useEffect(() => {
let ignore = false;
fetchTea(teaId).then((data) => {
if (!ignore) setTea(data);
});
return () => {
ignore = true;
};
}, [teaId]);
if (!tea) return <p>Loading…</p>;
return <h2>{tea.name}</h2>;
}Lines 3 to 12 are the state, the effect, and the cleanup that ignores a late answer, and line 13 is the loading state. With a loader, the router does that work. How to read the table: each row asks the same question of both ways to load the tea.
| Question | Fetching in an effect | A route loader |
|---|---|---|
| When does the request start? | After the tea page has rendered once, when the effect runs. | When the click happens, before the tea page renders. |
| What shows while it waits? | The new page, with its own Loading… text. | The old page, until the data is ready, unless you add pending UI. |
| Where does a failed request go? | Into an error state you write and show yourself. | To the route's ErrorBoundary, which useRouteError() reads. |
| Two nested routes that both need data? | The child's request waits until the parent has rendered. | The router starts both loaders at the same time. |
| What does the component do? | Asks for the data, cleans up, and tracks loading. | Reads the data with useLoaderData(). It's always there. |
Loaders are a router's answer for the data a page needs to show at all. For data that changes while the page is open, or that many pages share, a data library keeps a cache; a loader can call it too.
Show that the next page is on its way
In the loader example, click Mint tea and look at the page for 0.8 seconds: nothing changes. The old page stays until the new one is ready, which is better than a blank page, but nothing says your click worked. The router knows a navigation is going on, and useNavigation() tells your components:
import { createMemoryRouter, NavLink, Outlet, RouterProvider, useLoaderData, useNavigation, useRouteError } from "react-router@8.5.0?dev&deps=react@19.3.1,react-dom@19.3.1";import AddressBar from "./AddressBar.jsx";import { fetchTea } from "./api.js"; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaPage() { const tea = useLoaderData(); return ( <> <h2>{tea.name}</h2> <p>${tea.price}, {tea.stock} left</p> </> );} function TeaError() { const error = useRouteError(); return <p>Sorry: {error.message}</p>;} function Layout() { const navigation = useNavigation(); const loading = navigation.state === "loading"; return ( <> <AddressBar /> <nav> <NavLink to="/" end>Home</NavLink> <NavLink to="/teas/green">Green tea</NavLink> <NavLink to="/teas/mint">Mint tea</NavLink> <NavLink to="/teas/oolong">Oolong</NavLink> </nav> {loading && <p>Loading {navigation.location.pathname}…</p>} <div style={{ opacity: loading ? 0.5 : 1 }}> <Outlet /> </div> </> );} const router = createMemoryRouter([ { path: "/", Component: Layout, children: [ { index: true, Component: Home }, { path: "teas/:teaId", Component: TeaPage, loader: ({ params }) => fetchTea(params.teaId), ErrorBoundary: TeaError, }, ], },]); export default function App() { return <RouterProvider router={router} />;}Line 25 reads the navigation. Its state is "idle" when nothing is happening and "loading" while loaders run for the next page (and "submitting" while a form is sent). Line 36 says which address is loading, from navigation.location, and line 37 fades the old page. The menu is made of NavLinks, and a NavLink also knows when its own page is the one loading: it gets the class pending, which styles.css shows in italics.
Click Mint tea. For 0.8 seconds the page says Loading /teas/mint…, Home fades, and Mint tea in the menu is in italics. The address bar still says shop.example/: the router changes the address and the page together, when the data is ready.
navigation.state is "loading". When the data is ready, the router changes the address and shows the new page in one step.This is the same "keep the old page while the new one loads" you met in Transitions. React Router also starts its updates inside startTransition, so a page that suspends with Suspense while it renders keeps the old page on screen too, instead of a fallback.
A sort order that lives in the address
The path names the page. The search params, the part after ?, hold options for that page, like how a list is sorted: /teas?sort=price. Because they're part of the address, a link, a bookmark, and a refresh all bring the same list back, and Back undoes a change. The URL & URLSearchParams lesson covers their format.
import { createMemoryRouter, Link, Outlet, RouterProvider, useSearchParams } from "react-router@8.5.0?dev&deps=react@19.3.1,react-dom@19.3.1";import AddressBar from "./AddressBar.jsx"; const teas = [ { id: "green", name: "Green tea", price: 3 }, { id: "chai", name: "Chai", price: 5 }, { id: "mint", name: "Mint tea", price: 4 },]; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaList() { const [searchParams, setSearchParams] = useSearchParams(); const sort = searchParams.get("sort") ?? "name"; const sorted = teas.toSorted((a, b) => sort === "price" ? a.price - b.price : a.name.localeCompare(b.name), ); return ( <> <button onClick={() => setSearchParams({ sort: "name" })}>Sort by name</button> <button onClick={() => setSearchParams({ sort: "price" })}>Sort by price</button> <ul> {sorted.map((tea) => ( <li key={tea.id}>{tea.name}: ${tea.price}</li> ))} </ul> </> );} function Layout() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas?sort=price">Cheapest teas</Link> </nav> <Outlet /> </> );} const router = createMemoryRouter( [ { path: "/", Component: Layout, children: [ { index: true, Component: Home }, { path: "teas", Component: TeaList }, ], }, ], { initialEntries: ["/teas"] },); export default function App() { return <RouterProvider router={router} />;}Line 15 reads the search params with useSearchParams(), which works like a state hook kept in the address. Line 16 reads sort, or uses "name" when there isn't one, and lines 17 to 19 sort a copy of the list with toSorted. The buttons on lines 22 and 23 set the params, which adds a history entry, like a link. Line 57 starts the result at /teas.
Click Sort by price: the address becomes /teas?sort=price, and the list reads Green tea, Mint tea, Chai. Press Back: it's sorted by name again. The Cheapest teas link on line 39 goes straight to the sorted list, which is what a link from an email or an ad would do. A sort order kept in useState couldn't.
- Which tea's page you're looking at
- The Orders or Profile tab of your account
- The order a list of teas is sorted in
- A search term the shop has searched for
- Page 3 of the search results
- Whether the account menu is open
- A review you're still typing
- Whether a tea's photo is zoomed in
Sort each piece of state: is it a page (the path), an option for a page that a link should keep (the search params), or something only this visit needs (component state)?
Common mix-ups
- "A link in a React app loads a new page from the server." No. The router stops the page load, changes the address, and renders the matching route. Only the first visit, or a refresh, loads the page from the server.
- "Routing is built into React." No. React has no router. It comes from a library like React Router or TanStack Router, or from a framework built on one.
- "Use
navigate()for every menu item." No. Menus and lists of pages are links: they open in a new tab and screen readers announce them.navigateis for after something happens, like a placed order. - "Changing the page re-renders the whole app from scratch." No. Layout routes stay on the page and keep their state; only the part in the Outlet changes.
- "A loader runs after the page renders." No. It starts with the navigation, and the page renders when its data is ready, so
useLoaderData()already has it when the page renders. - "Search params are only for search boxes." No. They're for any option of a page that a link should keep: a sort order, a filter, a page number, a tab.
- "In React Router, the order of my routes decides which one wins." No. It ranks routes and picks the most specific. The tiny router in this lesson takes the first that fits, which is why its
*goes last.
Practice exercises
Most exercises use the tiny router, so the whole router is in the tabs to read. Press Edit & run to change the code, and use the drawn address bar to check your answer.
What does line 14 print? Answer first, then press Edit & run.
// The params when the path fits the pattern, or null when it doesn't.
function matchPath(pattern, path) {
const want = pattern.split("/").filter(Boolean);
const got = path.split("/").filter(Boolean);
if (want.length !== got.length) return null;
const params = {};
for (let i = 0; i < want.length; i += 1) {
if (want[i].startsWith(":")) params[want[i].slice(1)] = got[i];
else if (want[i] !== got[i]) return null;
}
return params;
}
console.log(matchPath("/teas/:teaId/reviews", "/teas/chai/reviews"));// The params when the path fits the pattern, or null when it doesn't.
function matchPath(pattern, path) {
const want = pattern.split("/").filter(Boolean);
const got = path.split("/").filter(Boolean);
if (want.length !== got.length) return null;
const params = {};
for (let i = 0; i < want.length; i += 1) {
if (want[i].startsWith(":")) params[want[i].slice(1)] = got[i];
else if (want[i] !== got[i]) return null;
}
return params;
}
console.log(matchPath("/teas/:teaId/reviews", "/teas/chai/reviews"));Both have three parts, so line 5 doesn't stop it. teas equals teas, :teaId saves chai under teaId, and reviews equals reviews. matchPath returns { teaId: "chai" }, and line 14 prints it.
Every page says Page not found, even Home. Find the bug in App.jsx and fix it. Before your fix, which route does Routes pick for /teas?
import AddressBar from "./AddressBar.jsx";import { Link, Routes } from "./router.jsx"; const teas = { green: "Green tea", mint: "Mint tea" }; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaList() { return ( <ul> <li><Link to="/teas/green">Green tea</Link></li> <li><Link to="/teas/mint">Mint tea</Link></li> <li><Link to="/teas/oolong">Oolong</Link></li> </ul> );} function TeaPage({ params }) { return <h2>{teas[params.teaId] ?? `We don't sell "${params.teaId}"`}</h2>;} function NotFound() { return <h2>Page not found</h2>;} const routes = [ { path: "*", Component: NotFound }, { path: "/", Component: Home }, { path: "/teas", Component: TeaList }, { path: "/teas/:teaId", Component: TeaPage },]; export default function App() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas">Teas</Link> </nav> <Routes routes={routes} /> </> );}import AddressBar from "./AddressBar.jsx";import { Link, Routes } from "./router.jsx"; const teas = { green: "Green tea", mint: "Mint tea" }; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaList() { return ( <ul> <li><Link to="/teas/green">Green tea</Link></li> <li><Link to="/teas/mint">Mint tea</Link></li> <li><Link to="/teas/oolong">Oolong</Link></li> </ul> );} function TeaPage({ params }) { return <h2>{teas[params.teaId] ?? `We don't sell "${params.teaId}"`}</h2>;} function NotFound() { return <h2>Page not found</h2>;} const routes = [ { path: "/", Component: Home }, { path: "/teas", Component: TeaList }, { path: "/teas/:teaId", Component: TeaPage }, { path: "*", Component: NotFound },]; export default function App() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas">Teas</Link> </nav> <Routes routes={routes} /> </> );}Routes takes the first route that fits, and * fits everything, so it won for every address, even /. With * last, it only wins when no other route fits. React Router ranks routes, so the order wouldn't matter there.
Click Teas, then Mint tea. The page doesn't say Mint tea. What does it say, and why? Fix it.
import AddressBar from "./AddressBar.jsx";import { Link, Routes } from "./router.jsx"; const teas = { green: "Green tea", mint: "Mint tea" }; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaList() { return ( <ul> <li><Link to="/teas/green">Green tea</Link></li> <li><Link to="/teas/mint">Mint tea</Link></li> <li><Link to="/teas/oolong">Oolong</Link></li> </ul> );} function TeaPage({ params }) { return <h2>{teas[params.teaId] ?? `We don't sell "${params.teaId}"`}</h2>;} function NotFound() { return <h2>Page not found</h2>;} const routes = [ { path: "/", Component: Home }, { path: "/teas", Component: TeaList }, { path: "/teas/:id", Component: TeaPage }, { path: "*", Component: NotFound },]; export default function App() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas">Teas</Link> </nav> <Routes routes={routes} /> </> );}import AddressBar from "./AddressBar.jsx";import { Link, Routes } from "./router.jsx"; const teas = { green: "Green tea", mint: "Mint tea" }; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaList() { return ( <ul> <li><Link to="/teas/green">Green tea</Link></li> <li><Link to="/teas/mint">Mint tea</Link></li> <li><Link to="/teas/oolong">Oolong</Link></li> </ul> );} function TeaPage({ params }) { return <h2>{teas[params.teaId] ?? `We don't sell "${params.teaId}"`}</h2>;} function NotFound() { return <h2>Page not found</h2>;} const routes = [ { path: "/", Component: Home }, { path: "/teas", Component: TeaList }, { path: "/teas/:teaId", Component: TeaPage }, { path: "*", Component: NotFound },]; export default function App() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas">Teas</Link> </nav> <Routes routes={routes} /> </> );}matchPath saves each param under the name written after :. The route said :id, so params was { id: "mint" } and params.teaId was undefined. With :teaId in the route, Mint tea shows.
Place order does nothing yet. Make it go to /thanks. What does the address bar show after you click it?
import AddressBar from "./AddressBar.jsx";import { Link, Routes } from "./router.jsx"; function Home() { return <h2>Welcome to the tea shop</h2>;} function Cart() { function placeOrder() { // Send the order to the server here, then show /thanks. } return ( <> <h2>Your cart: 2 teas</h2> <button onClick={placeOrder}>Place order</button> </> );} function Thanks() { return <h2>Thanks for your order!</h2>;} const routes = [ { path: "/", Component: Home }, { path: "/cart", Component: Cart }, { path: "/thanks", Component: Thanks },]; export default function App() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/cart">Cart</Link> </nav> <Routes routes={routes} /> </> );}import AddressBar from "./AddressBar.jsx";import { Link, Routes, navigate } from "./router.jsx"; function Home() { return <h2>Welcome to the tea shop</h2>;} function Cart() { function placeOrder() { // Send the order to the server here, then: navigate("/thanks"); } return ( <> <h2>Your cart: 2 teas</h2> <button onClick={placeOrder}>Place order</button> </> );} function Thanks() { return <h2>Thanks for your order!</h2>;} const routes = [ { path: "/", Component: Home }, { path: "/cart", Component: Cart }, { path: "/thanks", Component: Thanks },]; export default function App() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/cart">Cart</Link> </nav> <Routes routes={routes} /> </> );}navigate("/thanks") adds /thanks to the history and points the index at it, so the address bar says shop.example/thanks and Routes shows Thanks. It's called in the click handler, after the order is sent, never while the component renders.
After Place order, Back returns to the cart. Add a replace function to router.jsx and use it in placeOrder. Then click Cart, Place order, and Back. Where does Back take you?
import AddressBar from "./AddressBar.jsx";import { Link, Routes, navigate } from "./router.jsx"; function Home() { return <h2>Welcome to the tea shop</h2>;} function Cart() { function placeOrder() { // Send the order to the server here, then: navigate("/thanks"); } return ( <> <h2>Your cart: 2 teas</h2> <button onClick={placeOrder}>Place order</button> </> );} function Thanks() { return <h2>Thanks for your order!</h2>;} const routes = [ { path: "/", Component: Home }, { path: "/cart", Component: Cart }, { path: "/thanks", Component: Thanks },]; export default function App() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/cart">Cart</Link> </nav> <Routes routes={routes} /> </> );}import AddressBar from "./AddressBar.jsx";import { Link, Routes, replace } from "./router.jsx"; function Home() { return <h2>Welcome to the tea shop</h2>;} function Cart() { function placeOrder() { // Send the order to the server here, then: replace("/thanks"); } return ( <> <h2>Your cart: 2 teas</h2> <button onClick={placeOrder}>Place order</button> </> );} function Thanks() { return <h2>Thanks for your order!</h2>;} const routes = [ { path: "/", Component: Home }, { path: "/cart", Component: Cart }, { path: "/thanks", Component: Thanks },]; export default function App() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/cart">Cart</Link> </nav> <Routes routes={routes} /> </> );}replace swaps the current entry, /cart, for /thanks, so the history is /, /thanks. Back goes to /, the home page, and never to a cart whose order is already placed. with returns a new array, so the history is still a new object for useSyncExternalStore.
This is the loader example, where a click on a tea seems to do nothing for 0.8 seconds. Make Layout show Loading… while a page loads. What is navigation.state meanwhile?
import { createMemoryRouter, Link, Outlet, RouterProvider, useLoaderData, useRouteError } from "react-router@8.5.0?dev&deps=react@19.3.1,react-dom@19.3.1";import AddressBar from "./AddressBar.jsx";import { fetchTea } from "./api.js"; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaPage() { const tea = useLoaderData(); return ( <> <h2>{tea.name}</h2> <p>${tea.price}, {tea.stock} left</p> </> );} function TeaError() { const error = useRouteError(); return <p>Sorry: {error.message}</p>;} function Layout() { return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas/green">Green tea</Link> <Link to="/teas/mint">Mint tea</Link> <Link to="/teas/oolong">Oolong</Link> </nav> <Outlet /> </> );} const router = createMemoryRouter([ { path: "/", Component: Layout, children: [ { index: true, Component: Home }, { path: "teas/:teaId", Component: TeaPage, loader: ({ params }) => fetchTea(params.teaId), ErrorBoundary: TeaError, }, ], },]); export default function App() { return <RouterProvider router={router} />;}import { createMemoryRouter, Link, Outlet, RouterProvider, useLoaderData, useNavigation, useRouteError } from "react-router@8.5.0?dev&deps=react@19.3.1,react-dom@19.3.1";import AddressBar from "./AddressBar.jsx";import { fetchTea } from "./api.js"; function Home() { return <h2>Welcome to the tea shop</h2>;} function TeaPage() { const tea = useLoaderData(); return ( <> <h2>{tea.name}</h2> <p>${tea.price}, {tea.stock} left</p> </> );} function TeaError() { const error = useRouteError(); return <p>Sorry: {error.message}</p>;} function Layout() { const navigation = useNavigation(); return ( <> <AddressBar /> <nav> <Link to="/">Home</Link> <Link to="/teas/green">Green tea</Link> <Link to="/teas/mint">Mint tea</Link> <Link to="/teas/oolong">Oolong</Link> </nav> {navigation.state === "loading" && <p>Loading…</p>} <Outlet /> </> );} const router = createMemoryRouter([ { path: "/", Component: Layout, children: [ { index: true, Component: Home }, { path: "teas/:teaId", Component: TeaPage, loader: ({ params }) => fetchTea(params.teaId), ErrorBoundary: TeaError, }, ], },]); export default function App() { return <RouterProvider router={router} />;}While the tea's loader runs, navigation.state is "loading", so the paragraph shows. When the data arrives, the router renders the tea page and the state goes back to "idle". Layout is the right place: it stays on screen for every page.
Check your understanding
For each code question, find the address first, then follow what the router does with it.
Question 1 of 8What does this matchPath call print?
Read the code, then predict// The params when the path fits the pattern, or null when it doesn't. function matchPath(pattern, path) { const want = pattern.split("/").filter(Boolean); const got = path.split("/").filter(Boolean); if (want.length !== got.length) return null; const params = {}; for (let i = 0; i < want.length; i += 1) { if (want[i].startsWith(":")) params[want[i].slice(1)] = got[i]; else if (want[i] !== got[i]) return null; } return params; } console.log(matchPath("/teas/:teaId", "/teas/mint/reviews"));Choose an answer to see the explanation.
Question 2 of 8With these routes, what does the tiny router's Routes show at /teas?
Read the code, then predictJSXconst routes = [ { path: "*", Component: NotFound }, { path: "/teas", Component: TeaList }, ]; // The tiny router's Routes, at the address /teasChoose an answer to see the explanation.
Question 3 of 8After these four calls on the tiny router, what does history.entries hold?
Read the code, then predictJavaScript// The tiny router's history starts as { entries: ["/"], index: 0 }. navigate("/teas"); navigate("/teas/mint"); go(-1); navigate("/cart"); // What does history.entries hold now?Choose an answer to see the explanation.
Question 4 of 8Why does a router's Link draw a real
<a href>, and not a<button>?Choose an answer to see the explanation.
Question 5 of 8In React Router, when does a route's
loaderstart?Choose an answer to see the explanation.
Question 6 of 8In the loader example, you click Mint tea and its loader takes 0.8 seconds. What's on screen meanwhile?
Choose an answer to see the explanation.
Question 7 of 8In the nested example, you type in Layout's search box, then go from /account to /account/profile. What happens to the text?
Choose an answer to see the explanation.
Question 8 of 8Where should a list's sort order go, so a shopper can send the sorted list to a friend?
Choose an answer to see the explanation.
Key takeaways
- A router makes the URL the state that decides the page, so pages can be bookmarked, shared, refreshed, and reached with Back.
- The address lives outside React. A router keeps it in a store, the browser's history on a real site, and components subscribe to it.
- A route pairs a path pattern with a component. A param like
:teaIdfits one part of the path and is read withuseParams(). - Links draw real
<a href>elements and change the page without a page load.navigateis for moving after an action;replacekeeps Back sensible. - Nested routes draw shared layouts once, with an
<Outlet />for the level below; layouts stay and keep their state. - A route's loader starts with the click, and the page renders with its data.
useNavigation()shows that the next page is on its way. - Search params hold options a link should keep, like a sort order.
The address decides the page.
A router reads the URL, renders the routes that fit, and loads their data, without asking the server for a new page.
Coming next: State management libraries. It sorts the state an app keeps, including the address, and shows when a library like Zustand, Redux Toolkit, or Jotai helps.