Lesson 13 · Ecosystem
Routing (React Router)
Pages, parameters, nested layouts, state in the URL, loaders and protected routes.
Loading lesson…
Lesson 13 · Ecosystem
Pages, parameters, nested layouts, state in the URL, loaders and protected routes.
Loading lesson…
A single-page application is one single HTML page. If it stayed that way, the whole app would have one address – and the user couldn't send a colleague a link to a specific product, bookmark a page or go back with the Back button. That's why we need every "screen" to have its own address, even though it's still one page.
Imagine the app as a hotel:
/products/42 = "second floor, room 42". Whoever knows the number gets straight there.<Outlet /> component.?view=sea&cot=yes. Still the same room, just prepared differently.A small shop: a home page, a product list, a product detail and a "not found" page.
import { createBrowserRouter, RouterProvider, Outlet, Link, useParams } from 'react-router'const router = createBrowserRouter([{path: '/',element: <Layout />, // the corridor – always visiblechildren: [{ index: true, element: <Home /> }, // exactly "/"{ path: 'products', element: <ProductList /> },{ path: 'products/:productId', element: <ProductDetail /> },{ path: '*', element: <NotFound /> }, // anything else],},])createRoot(root).render(<RouterProvider router={router} />)function Layout() {return (<><nav><Link to="/">Home</Link><Link to="/products">Products</Link></nav><Outlet /> {/* the room door */}</>)}
The user clicks a link leading to /products/3. What the reception does:
<Link> doesn't cause a page reload. It just changes the address in the browser and lets the router know./ matches the start of the address → it renders Layout. In its children it finds products/:productId, which matches the rest.Layout renders with the navigation, and ProductDetail appears in place of <Outlet />. The navigation stayed the same – only the room changed.ProductDetail we find out the room number with useParams():function ProductDetail() {const { productId } = useParams() // "3" – always TEXT, not a number!const product = products.find((p) => p.id === Number(productId))if (!product) return <p>Product not found</p>return <h2>{product.name}</h2>}
The colon in :productId says: "anything can be here, remember it under this name". But the number on a room door is always written – it's text. Hence Number(productId).
What you see: The shop from the example above. At the top, a fake address bar with Back/Forward arrows, under it the navigation (the layout) and in the card the content of the current page (<Outlet />).
Try it:
/products, the button is highlighted (that's what NavLink can do) and the card shows the list. The navigation at the top stayed.* takes over and a 404 appears.The takeaway: Every screen has its address, the layout doesn't change and only the content in <Outlet /> changes. And all without reloading the page.
Loading the interactive part…
| API | What for |
|---|---|
<Link to> | Navigation without reloading the page. Relative paths are resolved against the current route. |
<NavLink> | A Link that knows it’s active (menus, tabs). |
<Outlet /> | The place in a layout where a nested route renders. |
useParams() | The variable parts of the address (:id). Always text – convert them! |
useNavigate() | Navigation from code (after submitting a form, after signing in). |
<Navigate to replace /> | A redirect during render (protected pages). |
useLocation() | The current address: pathname, search, state. |
When you book a hotel room "with a sea view and a cot", you want that to still hold tomorrow, and you want a colleague you send the booking to to get the same. So the requests belong in the booking, not in your head.
Filters, sorting, paging, a search term – these are all requests about a page's content. If they were only in useState, they would disappear after a reload and a colleague opening the link would see something else. So they belong in the search params – the part of the address after the question mark: /products?category=fruit&sort=price.
function Catalog() {const [searchParams, setSearchParams] = useSearchParams()// Reading: like from a map, a missing value is nullconst category = searchParams.get('category') // "fruit" | nullconst sort = searchParams.get('sort') ?? 'name'// Writing: changes the address → the component re-renders with the new valuesfunction setCategory(value: string | null) {setSearchParams((prev) => {const next = new URLSearchParams(prev)if (value) next.set('category', value)else next.delete('category')return next})}const visible = products.filter((p) => !category || p.category === category)// …}
useSearchParams works much like useState: it returns the current values and a function to change them. Only the "notebook" is the address.new URLSearchParams(prev)) and change just one – the other requests stay.What you see: A catalogue with a search, categories and sorting. All three filters live only in the address – watch the address bar at the top. The demo starts at the address /?category=fruit, as if someone had sent it to you.
Try it:
q parameter changes with every letter, but it doesn't clutter the history (the code uses replace).The takeaway: State in the address survives a reload, can be sent as a link and works with the Back and Forward buttons. The component just reads it and calculates from it.
Loading the interactive part…
In a bad hotel they send you to your room and the housekeeper starts cleaning only when you walk in. You wait in the doorway. In a good hotel the room is ready before you walk in.
Without a loader, a component loads data itself – in an effect, only once it has rendered. The user first sees an empty page with "Loading…", and when the page has nested parts, each starts loading only after its parent (a so-called waterfall). A loader is the housekeeper: the router calls it before rendering the page and the component gets the data ready.
// The housekeeper: loads the data before the page showsasync function productLoader({ params }: LoaderFunctionArgs) {const product = await fetchProduct(Number(params.id))if (!product) throw new Response('Product not found', { status: 404 })return { product }}// The route: we assign the room a housekeeper and a "receptionist for problems"{ path: 'product/:id', loader: productLoader, element: <ProductPage />, errorElement: <ErrorPage /> }// The component: no useEffect, no "Loading…" – the data is already therefunction ProductPage() {const { product } = useLoaderData<typeof productLoader>()return <h2>{product.name}</h2>}
What happens after clicking a link to a product:
useNavigation().state equals 'loading' – you can show a progress indicator.useLoaderData.Response with 404), the errorElement renders instead of the page – the reception says "we don't have a room like that".What you see: A product list and a detail, both with a loader. The fake API replies with a delay. During navigation "Loading the next page…" appears above the card.
Try it:
The takeaway: The data is loaded before rendering. The component only handles the display, and errorElement takes care of errors.
Loading the interactive part…
| React Router mode | What it is | When |
|---|---|---|
| Declarative | <BrowserRouter> + <Routes>/<Route> in JSX | Simple apps, gradual migration. |
| Data | createBrowserRouter + loader/action/errorElement | SPAs where you want data loading tied to routes. |
| Framework | A Vite plugin, file-based routes, SSR, typegen | New full-stack apps (the successor of Remix). |
You can't just walk onto a hotel's VIP floor – there's a doorman by the lift. If you don't have a key card, they send you to the reception, which notes down where you wanted to go. Once that's sorted, they send you straight there.
In the router, the doorman is a layout route without its own address. It wraps the protected pages and decides whether to let you through (<Outlet />) or redirect you to sign in:
function RequireAuth() {const { user } = useAuth()const location = useLocation()if (!user) {// to the reception – and note down where the guest wanted to goreturn <Navigate to={`/login?redirect=${location.pathname}`} replace />}return <Outlet /> // let them through}// A route without a path: adds nothing to the address, just wraps its children{ element: <RequireAuth />, children: [{ path: 'account', element: <Account /> },{ path: 'account/settings', element: <Settings /> },]}
replace makes sure the redirect isn't written into the history – the Back button then doesn't lead to the doorman again. Finishing the sign-in with a return to where the user wanted to go is the task of the second challenge.
Two pages, two links. Watch how the address at the top changes.
CHALLENGE: A link to the second page The routes are finished: '/' (Home) and '/about' (About us). The links between them are missing. 1. On the Home page add <Link to="/about">About us</Link> 2. On the About page add <Link to="/">Back home</Link>
Loading the interactive part…
The mini e-shop practises sections 1 and 2: a layout, a list, a detail, a filter in the address and a 404. The protected pages complete section 4 – the doorman and the return to where the user originally wanted to go.
The complete routing of a small app: a layout, a list, a detail, state in the address and a 404.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
A pattern you'll find in every app with sign-in: a doorman + a return to where the user originally wanted to go.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
<Outlet /> = a shared layout.Link/NavLink in JSX, useNavigate in handlers, <Navigate /> during render. Never <a href>.errorElement handles errors and 404s.<Navigate replace> to the sign-in and back again.createMemoryRouter, Link, Outlet, RouterProvider, AddressBar)createMemoryRouter, Link, Outlet, RouterProvider – from the library react-router: the routing library (lesson 13).AddressBar – from the file src/lessons/13-routing/demos/AddressBar.en.tsx. A fake address bar. The demos run in a memory router, so you wouldn't see the URL otherwise./** A fake address bar – the demos run in a memory router, so let's show the URL. */export function AddressBar() {const location = useLocation()const navigate = useNavigate()return (<div className="row" style={{ marginBottom: 12 }}><button className="btn btn-small btn-ghost" onClick={() => navigate(-1)} aria-label="Back">←</button><button className="btn btn-small btn-ghost" onClick={() => navigate(1)} aria-label="Forward">→</button><code style={{ flex: 1, padding: '0.3rem 0.6rem' }}>https://shop.example{location.pathname}{location.search}</code></div>)}
row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).