1. Route Loaders
Fetching data inside a useEffect after a component mounts means the user
sees an empty or loading component first, then data pops in a moment later — a
waterfall. A loader runs before React Router renders the
route at all, so the data is already available on the first render.
import { createBrowserRouter } from 'react-router';
const router = createBrowserRouter([
{
path: 'products/:productId',
element: <ProductDetailPage />,
loader: async ({ params }) => {
const response = await fetch(`/api/products/${params.productId}`);
if (!response.ok) throw new Response('Not Found', { status: 404 });
return response.json();
},
},
]);
import { useLoaderData } from 'react-router';
function ProductDetailPage() {
const product = useLoaderData(); // already resolved -- no loading state to handle here
return <h1>{product.name}</h1>;
}
The loader receives the same params you'd get from useParams(),
runs when navigation to that route starts, and React Router waits for it to resolve
before rendering ProductDetailPage at all. useLoaderData()
then just reads the already-resolved result — no isLoading flag to check
inside the component, because the component never renders until the data exists.
A loader is React Router's built-in answer to "fetch before render," and is enough for many apps. Week 8 introduces TanStack Query, which adds caching, background refetching, and mutation handling on top — the two can be combined (a loader can even call a Query client), but for now, treat loaders as the simpler, router-native tool.
2. Pending UI with useNavigation
A loader still takes time over the network — the difference is where the
waiting happens. useNavigation() exposes the router's current navigation
state, so you can show pending UI (like a top-of-page progress bar) without each page
managing its own loading flag:
import { Outlet, useNavigation } from 'react-router';
function AppLayout() {
const navigation = useNavigation();
const isNavigating = navigation.state === 'loading';
return (
<div>
{isNavigating && <div className="top-progress-bar" />}
<Outlet />
</div>
);
}
navigation.state is "idle", "loading" (a loader
is running for the next page), or "submitting" (an action, Section 3, is
running). Placing this check in the shared layout means every route gets consistent
pending UI for free, instead of each page reimplementing its own spinner.
3. Route Actions
An action is a loader's counterpart for writes: a function that handles a form submission for a route, runs the mutation, and lets React Router manage the pending/error state the same way it does for loaders.
const router = createBrowserRouter([
{
path: 'contacts/new',
element: <NewContactPage />,
action: async ({ request }) => {
const formData = await request.formData();
const name = formData.get('name');
const response = await fetch('/api/contacts', {
method: 'POST',
body: JSON.stringify({ name }),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) throw new Response('Failed to create contact', { status: 500 });
return redirect('/contacts'); // navigate away on success
},
},
]);
import { Form, useNavigation } from 'react-router';
function NewContactPage() {
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post">
<input name="name" required />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save contact'}
</button>
</Form>
);
}
React Router's own <Form> (capital F, imported from
react-router) looks like a plain HTML form but intercepts the submit,
bundles the fields into a FormData object, and routes it to the matching
route's action — no manual onSubmit, no manual
fetch call, and useNavigation reports
"submitting" automatically while it runs.
React Hook Form (Week 5) is still the right tool when you need rich client-side validation before submission. Router actions shine for simpler forms where the interesting logic is the server round-trip itself — the two approaches solve different problems and can coexist in the same app.
4. errorElement & Not-Found Routes
When a loader or action throws — like the 404 Response from Section 1 —
React Router doesn't crash the whole app. It renders the nearest route's
errorElement instead:
{
path: 'products/:productId',
element: <ProductDetailPage />,
errorElement: <ProductError />,
loader: productLoader,
}
import { useRouteError, isRouteErrorResponse } from 'react-router';
function ProductError() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) {
return <p>That product doesn't exist.</p>;
}
return <p>Something went wrong loading this product.</p>;
}
isRouteErrorResponse distinguishes a deliberate Response
thrown from a loader/action (like the 404 from Section 1) from an unexpected
JavaScript error, letting you show a precise message for expected failure cases and a
generic one for everything else.
5. Protected Routes
A protected route needs to check auth status before rendering, and redirect to a
login page if the user isn't authenticated. The cleanest implementation is a wrapper
component used as a parent route's element:
import { Navigate, Outlet, useLocation } from 'react-router';
import { useAuth } from './useAuth'; // your own auth hook/context
function RequireAuth() {
const { user } = useAuth();
const location = useLocation();
if (!user) {
// Redirect to login, remembering where the user was trying to go
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />; // authenticated -- render whichever child route matched
}
const router = createBrowserRouter([
{ path: '/login', element: <LoginPage /> },
{
element: <RequireAuth />, // no path -- wraps its children with the auth check
children: [
{ path: '/dashboard', element: <DashboardPage /> },
{ path: '/settings', element: <SettingsPage /> },
],
},
]);
A route object with children but no path acts as a pure
wrapper — it doesn't match a URL segment itself, it just runs
RequireAuth's check before any of its children can render.
replace on the Navigate avoids leaving the blocked page in
browser history, so the back button doesn't return the user to a bounce-redirect loop.
RequireAuth hides a page from a user without a valid session, but the JavaScript bundle and route config are always visible in the browser. Any data this route reveals must also be protected by real server-side authorization — client-side route guards are about experience, never the actual security boundary.
6. Hands-on Exercise
Add a loader, an action, and a protected route to last week's catalog
Extend Week 6's product catalog with this week's router-native data layer.
Requirements:
- Replace the
useParams-based lookup on the product detail page with a realloaderthat "fetches" the product (simulate with aPromise+setTimeoutover your in-memory array), throwing a 404Responsewhen the ID doesn't match. - Add an
errorElementfor the detail route that distinguishes a 404 from a generic failure usingisRouteErrorResponse. - Add a top-of-page progress bar in
AppLayoutdriven byuseNavigation().state === 'loading'. - Add a simple
/loginpage and a fakeuseAuthhook (a piece of state toggled by a "Log in" button is enough) and wrap a new/adminroute inRequireAuth, redirecting to/loginwhen logged out. - Add a
/contacts/newroute with a React Router<Form>and anactionthat adds the contact to your in-memory list and redirects to a contacts list route on success.
To simulate a network delay for your loader without a real backend: await new Promise(resolve => setTimeout(resolve, 500)); before returning the data — this is what makes the progress bar from step 3 actually visible.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What's the key timing difference between fetching data in a loader vs. fetching it in a useEffect after the component mounts?
What's the key timing difference between fetching data in a loader vs. fetching it in a useEffect after the component mounts?
A loader runs and resolves before the route's component renders at all, so the component's first render already has real data — no loading state to render through. An effect-based fetch renders the component first (with no data yet), then fetches, then re-renders once the data arrives — a visible waterfall the loader approach avoids.
Q2
What does React Router's <Form> component do differently from a plain HTML <form>?
What does React Router's <Form> component do differently from a plain HTML <form>?
It intercepts the submit event, bundles the fields into a FormData object, and routes it to the matching route's action client-side — instead of triggering the browser's default full-page form submission. It also automatically updates useNavigation().state to "submitting" while the action runs.
Q3
Why does a route object with children but no path work as an auth guard?
Why does a route object with children but no path work as an auth guard?
Without a path, the route never matches a URL segment on its own — it exists purely to wrap its children, meaning its element (like RequireAuth) always runs first and its <Outlet /> is the only way any child route can actually render. That gives the wrapper a chance to redirect before any protected child ever mounts.
Q4
Why isn't a client-side RequireAuth check sufficient on its own to secure a page's data?
Why isn't a client-side RequireAuth check sufficient on its own to secure a page's data?
All client-side code — including the route guard's logic — is downloaded to and runs in the user's own browser, where it can be inspected or bypassed. It's a UX mechanism for hiding pages from users without a session, not a security boundary; the server must independently verify authorization on every request for any data the page displays.