One screen is not an app. We want a list page, a page for one job, and later sign-in pages, each with its own address that can be bookmarked and shared. A single page application (SPA) does this without reloading: React Router watches the address bar and draws the right component, and links change the address without asking the server for a new page.
Set up the router
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './index.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
BrowserRouter wraps the app and makes the current address available to everything below it. The react-router-dom package is added in package.json; run npm install after you copy it.
Declare the routes
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout.jsx';
import JobsPage from './pages/JobsPage.jsx';
import JobDetailPage from './pages/JobDetailPage.jsx';
import NotFoundPage from './pages/NotFoundPage.jsx';
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<JobsPage />} />
<Route path="jobs/:id" element={<JobDetailPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
}
<Routes>picks the best matching route for the current address.<Route index element={<JobsPage />} />is the page at the parent's own path (/).path="jobs/:id"has a parameter:/jobs/64f...matches, and the part after/jobs/is calledid.path="*"matches everything else, so unknown addresses get your own 404 page and not a blank screen.- The routes are nested inside
<Route element={<Layout />}>. A route without a path that only has an element is a *layout route*.
A layout with an Outlet
import { Outlet } from 'react-router-dom';
import Header from './Header.jsx';
// Every page is drawn inside <Outlet />, under the same header.
export default function Layout() {
return (
<>
<Header />
<main className="container">
<Outlet />
</main>
</>
);
}
<Outlet /> is the hole where the matching child page is drawn. The header stays on screen while the page under it changes. Nothing is reloaded, and the header is not drawn again from scratch. In lesson 32 the same idea gives us a ProtectedRoute that wraps all private pages.
import { NavLink } from 'react-router-dom';
// NavLink is a link that does not reload the page, and gets the class "active" on the current page.
export default function Header() {
return (
<header className="site-header">
<strong>Job Tracker</strong>
<nav className="nav">
<NavLink to="/" end>My jobs</NavLink>
</nav>
</header>
);
}
<NavLink> is a link that does not reload the page, and it adds the class active when it points to the current page, so you can style the current item. The end prop makes / match only the exact address and not every address that starts with /. For links that do not need the active style use <Link>. Never use a plain <a href> for pages inside your app, because that asks the server for a whole new page.
The list and detail pages
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import JobCard from '../components/JobCard.jsx';
import JobForm from '../components/JobForm.jsx';
import { api, setToken } from '../api.js';
export default function JobsPage() {
const [jobs, setJobs] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
// TEMPORARY sign-in as the demo user (see lesson 28). Lesson 30 moves it into a context; lesson 32 removes it.
const login = await api('/auth/login', { method: 'POST', body: { email: 'demo@example.com', password: 'Demo12345' }, signal: controller.signal });
setToken(login.token);
const page = await api('/jobs?limit=50', { signal: controller.signal });
setJobs(page.items);
} catch (err) {
if (err.name !== 'AbortError') setError(err.message);
} finally {
setLoading(false);
}
})();
return () => controller.abort();
}, []);
const changeStatus = (id, status) => setJobs((cur) => cur.map((j) => (j.id === id ? { ...j, status } : j)));
const addJob = (values) => setJobs((cur) => [{ ...values, id: `local-${Date.now()}` }, ...cur]);
return (
<>
<h1>My jobs ({jobs.length})</h1>
<JobForm onSubmit={addJob} />
{loading && <p className="muted">Loading your jobs…</p>}
{error && <p className="error" role="alert">{error}</p>}
{jobs.map((job) => (
<div key={job.id}>
<JobCard job={job} onStatusChange={changeStatus} />
<p className="muted"><Link to={`/jobs/${job.id}`}>Open details</Link></p>
</div>
))}
</>
);
}
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../api.js';
import StatusBadge from '../components/StatusBadge.jsx';
export default function JobDetailPage() {
const { id } = useParams(); // the :id from the URL /jobs/:id
const [job, setJob] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
const controller = new AbortController();
api(`/jobs/${id}`, { signal: controller.signal })
.then(setJob)
.catch((err) => err.name !== 'AbortError' && setError(err.status === 404 ? 'That job was not found.' : err.message));
return () => controller.abort();
}, [id]); // run again if the id in the URL changes
if (error) return <p className="error" role="alert">{error} <Link to="/">Back to my jobs</Link></p>;
if (!job) return <p className="muted">Loading…</p>;
return (
<article className="card">
<h1>{job.role}</h1>
<p>{job.company}{job.location ? ` · ${job.location}` : ''}</p>
<StatusBadge status={job.status} />
{job.notes && <p>{job.notes}</p>}
<p><Link to="/">← Back to my jobs</Link></p>
</article>
);
}
useParams() returns the parameters from the address: const { id } = useParams(). The detail page fetches that job in an effect that lists id in its dependency array. If the person goes straight from one job's address to another, the effect runs again. A 404 from the API is turned into a friendly message with a link back, instead of a raw error.
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<>
<h1>Page not found</h1>
<p>That address does not exist. <Link to="/">Go to my jobs</Link>.</p>
</>
);
}
Your task
- Run
npm installinclientand open the app. Click “Open details” on a job. Watch the address change and notice that the page does not reload. - Use the browser's back and forward buttons. They work, because the router uses the history API.
- Type a wrong address such as
/nothing. You should see the 404 page. - Add an
/aboutroute with a simple page and aNavLinkfor it.
How to check it worked
- The address changes without a full reload.
- The header stays while the page changes.
- Unknown addresses show your 404 page.
Summary
BrowserRouter,RoutesandRoutemap addresses to components;:idcreates a parameter.- A layout route with
<Outlet />shares a header across pages. - Use
LinkandNavLinkfor in-app navigation, and add a*route for 404.