React 12 · Ecosystem
Routing (React Router)
Pages, parameters, nested layouts, state in the URL, loaders and protected routes.
Loading lesson…
React 12 · 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.
Task is in the comments in the code below – at the top of the file and at the places marked TODO.
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. What should happen is in the comments in the code.
Medium and hard challenges unlock once you sign in. Like the whole course, they are free – just an e-mail, no password and no payment.
A pattern you'll find in every app with sign-in: a doorman + a return to where the user originally wanted to go. What should happen is in the comments in the code.
Medium and hard challenges unlock once you sign in. Like the whole course, they are free – just an e-mail, no password and no payment.
<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 React 12).AddressBar – from the file src/lessons/12-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).