Two topics in one post because they are the two places a single-page app most often stops feeling like the web. Routing is what keeps the back button, the address bar and shareable links working once JavaScript is drawing the pages. Forms are where your users actually touch the app, and where the most careless code in most codebases tends to be.
The URL is state
The rule from post 6, restated because it is the whole reason routing matters: if a value should survive a refresh, be shareable as a link, and work with the back button, it belongs in the URL.
Which product is open. Which tab. Which filter. Page 3. Put those in component state instead and you have quietly broken things every user expects — someone bookmarks a filtered list and gets the unfiltered one, someone presses back and leaves the app entirely.
Routes map URLs to screens
A router matches the current path and renders the matching screen, and it changes the URL without a full page load — so the app keeps its state and does not re-download everything.
<Route path="/" element={<HomePage />} />
<Route path="/menu" element={<MenuPage />} />
<Route path="/checkout" element={<CheckoutPage />} />
<Route path="/order-confirmation/:orderId" element={<OrderConfirmationPage />} />
<Route path="/login" element={<LoginPage />} />The :orderId segment is a parameter — the screen reads it and fetches that order.
That is the URL carrying state, exactly as above.
Always define a catch-all. Without one, a typo'd URL renders nothing at all, which reads as a broken app rather than a wrong address:
<Route
path="*"
element={
<div className="container py-5 text-center">
<h1 className="h4">Page not found</h1>
</div>
}
/>Nested layouts
Sections that share a shell — an admin area with its own sidebar — nest. The parent renders the chrome plus an outlet, and the children render into it:
<Route
path="/admin"
element={
<ProtectedRoute requireAdmin>
<AdminLayout />
</ProtectedRoute>
}
>
{/* `index` is the route shown at /admin itself. */}
<Route index element={<AdminReportsPage />} />
<Route path="products" element={<AdminProductsPage />} />
<Route path="toppings" element={<AdminToppingsPage />} />The demo app makes a structural point about this that is worth internalising:
Guarding the PARENT means every admin page inherits the check — a new tab cannot be added unprotected by accident.
That is security by construction rather than by discipline. If each child carried its own guard, the fifteenth one added in a hurry would eventually be missing it.
Guarded routes, and the two bugs everyone ships
A guard wraps a page and sends unauthorised visitors somewhere else. It is easy to write and easy to write with two specific bugs in it.
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requireAdmin && !isAdmin) {
return <Navigate to="/" replace />;
}Bug one: the flash of the login page on refresh. If the app is still checking a
stored token, isAuthenticated is briefly false — and a perfectly valid admin gets
bounced to the sign-in screen. You need a third state, not a boolean:
if (initialising) {
return (
<div className="text-center py-5">
<Spinner animation="border" variant="danger" role="status">
<span className="visually-hidden">Checking your session…</span>
</Spinner>
</div>
);
}Bug two: the back button bouncing. replace swaps the current
history entry instead of adding one. Without it, pressing Back after signing in returns you to the
redirect, which immediately redirects you forward again — a loop the user cannot escape.
And the courtesy that separates a good app from an irritating one: remember where they were going. The guard stashes the attempted location, and the login page reads it back:
const redirectTo =
(location.state as { from?: { pathname: string } } | null)?.from?.pathname ?? '/';One last thing, and the demo app is blunt about it in a comment on the guard itself:
NOTE: this is a usability guard, not a security control. Anyone can edit client-side JavaScript. The real enforcement is the backend rejecting requests without a valid ADMIN token.
That is post 9 in one sentence.
Links, not onClick
Navigation belongs in an anchor. A <div onClick={() => navigate('/menu')}>
cannot be middle-clicked, cannot be opened in a new tab, is not focusable, is not announced as a
link, and is invisible to a crawler. Routers give you a link component that renders a real
<a href> and intercepts the click — use it.
Forms
Controlled inputs
The framework holds the value and the input renders from it. One source of truth, so validation, formatting and resetting all work on the same data.
<Form.Control
id={`${formId}-email`}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
/>Three attributes there are doing unglamorous, important work. type="email" gets a
better mobile keyboard and free format checking. required is browser-level validation
with no code. autoComplete="username" is what lets a password manager fill the form —
get this wrong and you have quietly made signing in harder for a large number of people.
Labels that actually work
<Form.Label htmlFor={`${formId}-email`}>Email</Form.Label>The id is generated rather than hard-coded, because the same form can appear twice on a page and
duplicate ids break the association silently. A label wired up properly gives you three things at
once: clicking it focuses the input, a screen reader announces the field, and a test can find it by
its label. That last one is why post 11 can write
getByLabel('Email').
Submitting
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
try {
await login(email, password);
navigate(redirectTo, { replace: true });
} catch {
// The error message is already surfaced through AuthContext; nothing to do here.
}
}Use a real <form> with a submit handler rather than a button with an onClick.
That is what makes Enter submit, and it is what browsers recognise as a login form.
preventDefault stops the browser doing its own full-page navigation.
And disable the submit button while the request is in flight, or a double-click places two orders:
<Button type="submit" variant="primary" className="w-100" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</Button>Validation happens in three places
| Where | What for | Trustworthy? |
|---|---|---|
The browser — required, type, min | Instant feedback, free | No |
| Your code — on change or on submit | Rules the browser cannot express; better messages | No |
| The server | Actually enforcing it | Yes — the only one |
Client validation is a user-experience feature. Anyone can open developer tools and send whatever they like straight to your API, so every rule that matters must exist on the server too. This is not duplication to be eliminated — the two have different jobs. The backend side of it.
Showing a server error on the right field
The server rejects an email that is already registered. That belongs under the email box, not in a banner. This is what the structured error from post 7 was for:
/** Field errors as a lookup, for rendering next to inputs. */
fieldErrors(): Record<string, string> {Catch the error, call it, and render each message beside its field.
When to show it
Validating on every keystroke tells someone their email is invalid after they have typed one character, which is nagging. The pattern that feels right: validate a field when it loses focus, switch to live validation for a field that has already errored so they can see it become correct, and validate everything on submit.
When to reach for a form library
Hand-rolled state is fine for a login box. By the time a form has fifteen fields, cross-field rules, arrays of repeated groups and a schema you also want to validate against on the server, a library (React Hook Form, plus Zod for the schema) is less code and fewer re-renders. Do not start there for two inputs.
The one thing to take from this post
Keep the browser's own behaviour rather than reimplementing it. Real links, real forms, real labels, real submit — you get history, keyboard support, autofill, password managers and screen reader support for free. And guard the parent route, never each child, so the next page added cannot be the one that forgot.