Week 7: Loaders, Actions & Protected Routes

Last week's routes rendered a component, and that component then fetched its own data with an effect — the Week 2 pattern. React Router can do better: a loader fetches a route's data before it renders, and an action handles a form submission through the same routing system. This week also covers the pattern every app with a login screen needs: protecting a route from unauthenticated access.

Module 5 of 13 Week 7 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Fetch a route's data with a loader instead of an effect
  • Handle form submissions through a route action
  • Build a protected route that redirects unauthenticated users

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.

route config with a loader
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();
    },
  },
]);
ProductDetailPage.tsx
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.

Loaders vs. TanStack Query (next week)

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:

AppLayout.tsx
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.

route config with an action
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
    },
  },
]);
NewContactPage.tsx
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.

This isn't the only way to submit a form

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:

route config with error handling
{
  path: 'products/:productId',
  element: <ProductDetailPage />,
  errorElement: <ProductError />,
  loader: productLoader,
}
ProductError.tsx
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:

RequireAuth.tsx
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
}
route config
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.

Client-side protection is UX, not security

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

Hands-on

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:

  1. Replace the useParams-based lookup on the product detail page with a real loader that "fetches" the product (simulate with a Promise + setTimeout over your in-memory array), throwing a 404 Response when the ID doesn't match.
  2. Add an errorElement for the detail route that distinguishes a 404 from a generic failure using isRouteErrorResponse.
  3. Add a top-of-page progress bar in AppLayout driven by useNavigation().state === 'loading'.
  4. Add a simple /login page and a fake useAuth hook (a piece of state toggled by a "Log in" button is enough) and wrap a new /admin route in RequireAuth, redirecting to /login when logged out.
  5. Add a /contacts/new route with a React Router <Form> and an action that adds the contact to your in-memory list and redirects to a contacts list route on success.
Hint

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?

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>?

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?

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?

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.