Routing
In short: Routing connects the address with the page: React Router shows the component of the route, links do not reload, and params like /product/:id come from the address.
Problem
A link that leads nowhere
The shop has a catalogue and a page for each product. Open "Fresh milk" and go back.
Console
It looks fine. But which page you see lives only in the state of the component (productId). The address in the browser is the same for the catalogue and for every product. So:
- you cannot send a friend a link to the milk: they will get the catalogue;
- "Reload" takes you back to the catalogue, because the state is lost;
- "Back" in the browser does not return to the catalogue, it leaves the shop;
- the products are buttons, not links: "Open in new tab" does not work.
(The sandbox has no address bar, so these things do not show in it. On a real site you see them at once.)
The idea
The address is the state
Every page needs its own address: / for the catalogue, /product/5 for the milk, /cart for the cart. Then the address says which page to show, not the state in memory.
The app stays one page (a single-page application): the browser loads the HTML once. On a link click the library stops the reload, changes the address through the browser's History API and draws another component. This is what the router does. It has a table of routes: an address pattern and a component for it.
React Router gives components for the table. Each Route says "for this address draw this element":
import { MemoryRouter, Routes, Route } from 'react-router';
export default function App() { return ( <MemoryRouter> <Header /> <Routes> <Route path="/" element={<Catalog />} /> <Route path="/product/:id" element={<ProductPage />} /> <Route path="/cart" element={<CartPage />} /> </Routes> </MemoryRouter> );}Routes draws only one of its routes: the one that matches the address. Header is outside Routes, so it shows on every page.
:id in /product/:id is a param: it stands for one segment of the address. One route covers all products.
See it
See how the address picks the page
<Link to={`/product/${p.id}`}>{p.name}</Link>
<Routes> <Route path="/" element={<Catalog />} /> <Route path="/product/:id" element={<ProductPage />} /> <Route path="/cart" element={<CartPage />} /> <Route path="*" element={<NotFound />} /></Routes>
function ProductPage() { const { id } = useParams(); const product = PRODUCTS.find((p) => p.id === Number(id)); return <h1>{product.name}</h1>;}
function NotFound() { return <h1>No such page</h1>;}Step by step, in words
- The router has a table: an address pattern and the component for it. The address now is /, the catalogue.
- A click on "Fresh milk". The router stops the page from reloading and only changes the address to /product/5.
- It compares the address with every row. "/product/:id" matches: :id stands for any one segment. The catch-all matches too, but it is more general and loses.
- From the match comes the param: id = "5". It is text, because the address is text. useParams() gives it to ProductPage, and Number(id) turns it into 5.
- ProductPage finds product 5 and draws its page. The same component draws every product: only the param is different.
- Now the address is /sale, and there is no such page yet. No row matches except the last one, so the router draws NotFound.
- The address is the state of the page. You can share it, reload it and go back to it, and the table always gives the same page.
The router knows nothing about products. It compares the text of the address with the patterns and passes the param to the component. What 5 means is up to the component.
The idea
In the sandbox and in a real project
The router of a real site reads and writes the address in the browser's address bar. That is BrowserRouter. The sandbox in the lessons is a page inside the lesson page and has no address of its own. So here it gives a clear error:
Console
In the sandbox we use MemoryRouter: a router that keeps the address in memory. Everything else is the same: the table, the links, the params. We show the address ourselves, at the bottom of the page.
In a real project the router wraps the app in main.jsx:
import { createRoot } from 'react-dom/client';import { BrowserRouter } from 'react-router';import App from './App.jsx';
createRoot(document.getElementById('root')).render( <BrowserRouter> <App /> </BrowserRouter>,);And one more thing the sandbox does not show: the server. When someone opens /product/5 directly or presses "Reload", the browser asks the server for that address. There is no such file. The server must return index.html for every address that is not a file, and then the router picks the page. The Vite dev server does this by itself. For nginx the try_files line does it:
location / { try_files $uri /index.html;}Try it
A page for every product
The table has the catalogue and the cart, and the products lead to /product/1, /product/5 and /product/7, but there is no such route. Add a route with a param for the product page. ProductPage now always shows the bread: let it take the id from the address. The check opens the milk, then the cheese from "See also", then the bread from the catalogue and looks at the heading and the address.
Console
Solution
App.jsximport { useState } from 'react';
import { MemoryRouter, Routes, Route } from 'react-router';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
import ProductPage from './ProductPage.jsx';
import CartPage from './CartPage.jsx';
import Address from './Address.jsx';
export default function App() {
const [items, setItems] = useState([]);
function add(product) {
setItems([...items, product]);
}
return (
<MemoryRouter>
<Header count={items.length} />
<main>
<Routes>
<Route path="/" element={<Catalog />} />
<Route path="/product/:id" element={<ProductPage onAdd={add} />} />
<Route path="/cart" element={<CartPage items={items} />} />
</Routes>
</main>
<Address />
</MemoryRouter>
);
}
Header.jsximport { NavLink } from 'react-router';
export default function Header({ count }) {
return (
<nav>
<NavLink to="/" end>Catalogue</NavLink>
<NavLink to="/cart">Cart ({count})</NavLink>
</nav>
);
}
Catalog.jsximport { Link } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function Catalog() {
return (
<>
<h1>Catalogue</h1>
<ul>
{PRODUCTS.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>, {money(p.price)}
</li>
))}
</ul>
</>
);
}
ProductPage.jsximport { Link, useParams } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function ProductPage({ onAdd }) {
const { id } = useParams();
const product = PRODUCTS.find((p) => p.id === Number(id));
if (!product) return <p>No such product</p>;
const others = PRODUCTS.filter((p) => p.id !== product.id);
function buy() {
onAdd(product);
}
return (
<section>
<h1>{product.name}</h1>
<p>{money(product.price)}</p>
<button type="button" onClick={buy}>Add to cart</button>
<h2>See also</h2>
<ul>
{others.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
);
}
CartPage.jsximport { money } from './products.js';
export default function CartPage({ items }) {
const total = items.reduce((sum, p) => sum + p.price, 0);
return (
<>
<h1>Cart</h1>
{items.length === 0 && <p>The cart is empty</p>}
<ul>
{items.map((p, i) => (
<li key={i}>{p.name}, {money(p.price)}</li>
))}
</ul>
<p>Total: {money(total)}</p>
</>
);
}
NotFound.jsximport { Link } from 'react-router';
export default function NotFound() {
return (
<>
<h1>No such page</h1>
<p>
<Link to="/">Back to the catalogue</Link>
</p>
</>
);
}
Address.jsximport { useLocation } from 'react-router';
// The sandbox has no address bar: we show the address here.
export default function Address() {
const location = useLocation();
return <footer>Address: {location.pathname}</footer>;
}
products.jsexport const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 5, name: 'Fresh milk', price: 1.6 },
{ id: 7, name: 'White cheese', price: 7.9 },
];
export const money = (x) => '€' + x.toFixed(2);
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
nav { display: flex; gap: 16px; background: #f3efe6; padding: 8px 16px; }
nav a { color: #1d4f8a; }
nav a[aria-current='page'] { font-weight: 700; text-decoration: none; }
main { padding: 0 16px; }
footer { border-top: 1px solid #ddd; padding: 4px 16px; color: #555; font-family: ui-monospace, monospace; }
button { font: inherit; }
In App.jsx the route is <Route path="/product/:id" element={<ProductPage onAdd={add} />} />. In ProductPage.jsx the param comes from const { id } = useParams();. The name id is the same as in the pattern :id.
The param is text: "5", while id in the data is a number. p.id === "5" is false for every product. Compare with Number(id).
Try it
Find the bug
Every product page says "No such product", and the products are in the data. Which line is to blame?
Pick the line with the bug.
id from useParams() is text ("5"), and p.id is a number. Strict === is never true. It should be p.id === Number(id).The idea
Links and moving from code
Links. A page of the app is reached with <Link to="/cart">. In the page it is a plain <a href="/cart">: "Open in new tab" works and a screen reader reads it as a link. On a plain click the router stops the reload (preventDefault) and only changes the address. If you write <a href> yourself, the browser loads the whole site again, and the cart in memory is gone.
For the menu there is NavLink: the link to the current page gets aria-current="page" and can have its own style. So both the eye and the screen reader know where you are.
Moving from code. Sometimes the page must change after an action, not after a link click: "Add to cart" adds the product and takes you to the cart.
import { useNavigate } from 'react-router';
export default function ProductPage({ onAdd }) { const navigate = useNavigate(); // … function buy() { onAdd(product); navigate('/cart'); }}useNavigate() is a hook: you call it in the body of the component and get a function navigate. The function itself you call where needed, for example in the handler.
You use a link when a person chooses where to go. You move from code when the page changes because of the result of an action.
Try it
Fill in
Where does the product page get the id from the address?
export default function ProductPage() {
const { id } = ();
const product = PRODUCTS.find((p) => p.id === Number(id));
return <h1>{product.name}</h1>;
}useParams: returns an object with the params of the matching route. For /product/:id it is { id: "5" }.The idea
No such page
If the address matches no route, the router draws nothing. The person sees an empty space under the menu and does not know what happened. So the last route catches everything else and draws "No such page" with a link to the catalogue.
<Route path="*" element={<NotFound />} />* matches any address. The order in Routes does not matter: React Router ranks the routes by how exact they are, and * wins only when no other route matches.
A page for a product that does not exist (/product/99) is a different case: the route matches, but the product is missing. That is up to the component, so ProductPage says "No such product".
More precisely, if you are curious
Nested routes. Pages often share a frame: the account with a menu on the left and sub-pages "Orders" and "Addresses". Routes are nested in each other, and the parent draws <Outlet /> where the child goes.
A query in the address. Search and filters are also state that is worth putting in the address: /?q=milk&category=2. It is read and written with useSearchParams().
The same component, a new param. From /product/5 to /product/7 React renders the same ProductPage again with a new id, so the calculations in the body are right. But its state (a "quantity" field, say) stays from the previous product. If it must start over, give it a key: <ProductPage key={id} /> in a component that reads id.
React Router modes. Here it is the "declarative" mode: Routes and Route in JSX. There is also a data mode (createBrowserRouter with a loader that loads data before the page) and a "framework" mode with Vite. The idea of a table, params and links is the same.
Guards. The "Checkout" page may require a login. The router can check before the move and redirect to the login: in data mode with a loader and redirect.
Exercise
The cart and 404
The menu has "Sale" (/sale), but that page does not exist yet and the space under the menu stays empty. And "Add to cart" adds the product, but the person stays on the product page. Add a route for everything else to NotFound (the component is ready) and make the button go to /cart after adding. The check opens "Sale", goes back with "Back to the catalogue", buys bread and cheese and looks at the cart, the address and the total.
Console
Solution
App.jsximport { useState } from 'react';
import { MemoryRouter, Routes, Route } from 'react-router';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
import ProductPage from './ProductPage.jsx';
import CartPage from './CartPage.jsx';
import NotFound from './NotFound.jsx';
import Address from './Address.jsx';
export default function App() {
const [items, setItems] = useState([]);
function add(product) {
setItems([...items, product]);
}
return (
<MemoryRouter>
<Header count={items.length} />
<main>
<Routes>
<Route path="/" element={<Catalog />} />
<Route path="/product/:id" element={<ProductPage onAdd={add} />} />
<Route path="/cart" element={<CartPage items={items} />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<Address />
</MemoryRouter>
);
}
Header.jsximport { NavLink } from 'react-router';
export default function Header({ count }) {
return (
<nav>
<NavLink to="/" end>Catalogue</NavLink>
<NavLink to="/cart">Cart ({count})</NavLink>
<NavLink to="/sale">Sale</NavLink>
</nav>
);
}
Catalog.jsximport { Link } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function Catalog() {
return (
<>
<h1>Catalogue</h1>
<ul>
{PRODUCTS.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>, {money(p.price)}
</li>
))}
</ul>
</>
);
}
ProductPage.jsximport { Link, useNavigate, useParams } from 'react-router';
import { PRODUCTS, money } from './products.js';
export default function ProductPage({ onAdd }) {
const { id } = useParams();
const navigate = useNavigate();
const product = PRODUCTS.find((p) => p.id === Number(id));
if (!product) return <p>No such product</p>;
const others = PRODUCTS.filter((p) => p.id !== product.id);
function buy() {
onAdd(product);
navigate('/cart');
}
return (
<section>
<h1>{product.name}</h1>
<p>{money(product.price)}</p>
<button type="button" onClick={buy}>Add to cart</button>
<h2>See also</h2>
<ul>
{others.map((p) => (
<li key={p.id}>
<Link to={`/product/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</section>
);
}
CartPage.jsximport { money } from './products.js';
export default function CartPage({ items }) {
const total = items.reduce((sum, p) => sum + p.price, 0);
return (
<>
<h1>Cart</h1>
{items.length === 0 && <p>The cart is empty</p>}
<ul>
{items.map((p, i) => (
<li key={i}>{p.name}, {money(p.price)}</li>
))}
</ul>
<p>Total: {money(total)}</p>
</>
);
}
NotFound.jsximport { Link } from 'react-router';
export default function NotFound() {
return (
<>
<h1>No such page</h1>
<p>
<Link to="/">Back to the catalogue</Link>
</p>
</>
);
}
Address.jsximport { useLocation } from 'react-router';
// The sandbox has no address bar: we show the address here.
export default function Address() {
const location = useLocation();
return <footer>Address: {location.pathname}</footer>;
}
products.jsexport const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 5, name: 'Fresh milk', price: 1.6 },
{ id: 7, name: 'White cheese', price: 7.9 },
];
export const money = (x) => '€' + x.toFixed(2);
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
nav { display: flex; gap: 16px; background: #f3efe6; padding: 8px 16px; }
nav a { color: #1d4f8a; }
nav a[aria-current='page'] { font-weight: 700; text-decoration: none; }
main { padding: 0 16px; }
footer { border-top: 1px solid #ddd; padding: 4px 16px; color: #555; font-family: ui-monospace, monospace; }
button { font: inherit; }
The route is <Route path="*" element={<NotFound />} />. In ProductPage.jsx: const navigate = useNavigate(); in the body, and navigate('/cart') in buy after onAdd(product). The cart is in App, so the count in the menu and CartPage see it at once.
The check also looks at the menu: on the cart page the link "Cart (1)" has aria-current="page". The router does that by itself.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: State · Derived state · Lifting state up · Shared state
- Continues in: Speed: when a component renders again · From the sandbox to a project · The shop front
- Tasks on this topic: A whole app