React · lesson 22 of 22
Beginner
5Intermediate
12- 6 Effects and useEffect
- 7 You Might Not Need an Effect
- 8 The Rules of Hooks
- 9 useReducer and useContext
- 10 Custom Hooks
- 11 Measuring Re-renders: memo, useMemo, and useCallback
- 13 Routing with React Router
- 14 Portals with createPortal
- 15 Error Boundaries
- 16 Suspense and lazy
- 17 Reading Promises with use, No Framework Needed
- 18 Retrying After an Error with react-error-boundary
Advanced
5Also part of React
5Route Loaders and Error Elements
Add a loader to a React Router 8 route, watch it log on every navigation, throw the data() helper for a 404 scoped to just that route, then reproduce and fix the real HydrateFallback warning the previous lesson's routes deliberately avoided.
By SkillByExample · Updated
Routing with React Router built a route tree with no loaders at all, specifically to avoid a warning this lesson now reproduces on purpose: a route whose data isn’t ready yet, loaded directly, with nothing told to show in the meantime. A loader function fetches before a route’s component renders, useLoaderData reads the result, and data(message, { status }) is React Router’s own helper for throwing a recognizable error — this lesson adds all three to the same users/:id route, triggers the real warning, then fixes it.
Project Setup
The same Vitest project as Routing with React Router — no new dependencies:
{
"name": "react-app",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"react": "19.3.1",
"react-dom": "19.3.1",
"react-router": "8.5.1"
},
"devDependencies": {
"vitest": "5.0.5",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
"@testing-library/jest-dom": "7.0.1",
"@vitejs/plugin-react": "6.1.2",
"jsdom": "30.1.3"
}
}
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.js"],
},
});
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
afterEach(() => {
cleanup();
});
Every command below runs inside Docker’s node:24.18.1 image, from /root/react-app, with NO_COLOR=1, and grep strips Vitest’s own Start at/Duration lines:
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-route-loaders-and-error-elements-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npm install --no-audit --no-fund
added 101 packages in 27s
A Loader Runs Before the Route Renders
userLoader runs before UserProfile ever renders, looks the :id param up in users, and either returns the match or throws React Router’s data helper with a 404 status. UserProfile reads the result with useLoaderData instead of looking users up itself:
// src/loaders/App.tsx
import { Outlet, Link, useParams, useLoaderData, useRouteError, isRouteErrorResponse, data, type LoaderFunctionArgs } from "react-router";
export const users: Record<string, { name: string }> = {
"1": { name: "Ada" },
"2": { name: "Grace" },
};
export function Layout() {
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/users/2">Grace</Link>
<Link to="/users/99">Nobody</Link>
</nav>
<Outlet />
</div>
);
}
export function Home() {
return <p>Pick a user from the nav.</p>;
}
export async function userLoader({ params }: LoaderFunctionArgs) {
console.error(`loader ran for users/${params.id}`);
const user = params.id ? users[params.id] : undefined;
if (!user) {
throw data("No such user", { status: 404 });
}
return user;
}
export function UserProfile() {
const user = useLoaderData() as { name: string };
const { id } = useParams();
return <p>User {id}: {user.name}</p>;
}
export function UserErrorPage() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <p>{error.status} {String(error.data)}</p>;
}
return <p>Unknown error</p>;
}
A route object’s loader key is how createMemoryRouter finds this function; clicking the Grace link, going back to Home, and clicking Grace again should run the loader twice — once per arrival at users/:id. In this test arrival and mount happen together, since navigating to Home and back unmounts and remounts UserProfile, so it can’t show a loader rerunning on an arrival that doesn’t also remount the component — only that it reruns on every arrival this particular route sees:
// src/loaders/LoaderNavigation.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Layout, Home, UserProfile, UserErrorPage, userLoader } from "./App.js";
const routes = [
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: "users/:id", element: <UserProfile />, loader: userLoader, errorElement: <UserErrorPage /> },
],
},
];
describe("navigating to a loader route", () => {
it("runs the loader again each time, not just on first mount", async () => {
const user = userEvent.setup();
const router = createMemoryRouter(routes, { initialEntries: ["/"] });
render(<RouterProvider router={router} />);
await user.click(screen.getByRole("link", { name: "Grace" }));
expect(screen.getByText("User 2: Grace")).toBeInTheDocument();
await user.click(screen.getByRole("link", { name: "Home" }));
await user.click(screen.getByRole("link", { name: "Grace" }));
expect(screen.getByText("User 2: Grace")).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-route-loaders-and-error-elements-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/loaders/LoaderNavigation.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/loaders/LoaderNavigation.test.tsx > navigating to a loader route > runs the loader again each time, not just on first mount
loader ran for users/2
stderr | src/loaders/LoaderNavigation.test.tsx > navigating to a loader route > runs the loader again each time, not just on first mount
loader ran for users/2
✓ src/loaders/LoaderNavigation.test.tsx > navigating to a loader route > runs the loader again each time, not just on first mount 328ms
Test Files 1 passed (1)
Tests 1 passed (1)
loader ran for users/2 prints twice — once for each arrival at /users/2 — with nothing logged for the trip through Home in between, because the index route has no loader of its own. userLoader logs through console.error rather than console.log because Vitest’s verbose reporter captures stdout and stderr as separate streams and can interleave lines from the two non-deterministically; keeping every diagnostic log in this lesson on the one stream keeps their relative order stable across runs. It doesn’t change anything about when or how often the loader runs.
A 404 Scoped to the Route
Clicking the “Nobody” link navigates to /users/99 — a route that matches, but whose loader finds no such id and throws data("No such user", { status: 404 }). UserErrorPage reads that with useRouteError and isRouteErrorResponse, exactly as the previous lesson’s root-level 404 did, but this errorElement sits on users/:id itself:
// src/loaders/NotFoundScoped.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Layout, Home, UserProfile, UserErrorPage, userLoader } from "./App.js";
const routes = [
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: "users/:id", element: <UserProfile />, loader: userLoader, errorElement: <UserErrorPage /> },
],
},
];
describe("a loader 404", () => {
it("renders inside the layout, with the nav still present", async () => {
const user = userEvent.setup();
const router = createMemoryRouter(routes, { initialEntries: ["/"] });
render(<RouterProvider router={router} />);
await user.click(screen.getByRole("link", { name: "Nobody" }));
expect(screen.getByText("404 No such user")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Home" })).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-route-loaders-and-error-elements-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/loaders/NotFoundScoped.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/loaders/NotFoundScoped.test.tsx > a loader 404 > renders inside the layout, with the nav still present
loader ran for users/99
✓ src/loaders/NotFoundScoped.test.tsx > a loader 404 > renders inside the layout, with the nav still present 382ms
Test Files 1 passed (1)
Tests 1 passed (1)
404 No such user renders where UserProfile would have, inside Layout’s Outlet — the Home link proves the nav never unmounted. That’s the opposite of the previous lesson’s unmatched-URL 404, where errorElement sat on the root route and an error there replaced the layout along with everything inside it. The rule is the same in both cases: React Router walks up from wherever the error happened to the nearest ancestor route with an errorElement, and only that route’s own output gets replaced.
Loading the Route Directly Warns About HydrateFallback
Every example so far reached /users/2 by clicking a Link after the app was already mounted. Landing on that URL directly — the very first render, before userLoader has resolved — is different, and it’s exactly what the previous lesson avoided by never giving any route a loader:
// src/loaders/HydrateFallbackMissing.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Layout, Home, UserProfile, UserErrorPage, userLoader } from "./App.js";
const routes = [
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: "users/:id", element: <UserProfile />, loader: userLoader, errorElement: <UserErrorPage /> },
],
},
];
describe("loading a loader route directly", () => {
it("warns, but still eventually renders the loaded user", async () => {
const router = createMemoryRouter(routes, { initialEntries: ["/users/2"] });
render(<RouterProvider router={router} />);
await waitFor(() => expect(screen.getByText("User 2: Grace")).toBeInTheDocument());
console.error(screen.getByRole("paragraph").textContent);
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-route-loaders-and-error-elements-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/loaders/HydrateFallbackMissing.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/loaders/HydrateFallbackMissing.test.tsx > loading a loader route directly > warns, but still eventually renders the loaded user
loader ran for users/2
No `HydrateFallback` element provided to render during initial hydration
stderr | src/loaders/HydrateFallbackMissing.test.tsx > loading a loader route directly > warns, but still eventually renders the loaded user
User 2: Grace
✓ src/loaders/HydrateFallbackMissing.test.tsx > loading a loader route directly > warns, but still eventually renders the loaded user 204ms
Test Files 1 passed (1)
Tests 1 passed (1)
The test still passes — User 2: Grace renders once the loader settles — but React Router’s own warning fires in between: No HydrateFallback element provided to render during initial hydration. Clicking a Link after mount is an ordinary navigation, with the previous route’s content still on screen while the new one’s loader runs; landing on the route directly has no previous content to fall back on, so React Router needs to be told what to show for that first, data-less render. Without an answer, it warns and renders nothing useful until the loader resolves. This is precisely the case Routing with React Router sidestepped by giving no route a loader at all.
Fixing It: a HydrateFallback Element
hydrateFallbackElement on the same route tells React Router what to render for that first, loader-still-pending pass:
// src/loaders/HydrateFallbackFixed.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Layout, Home, UserProfile, UserErrorPage, userLoader } from "./App.js";
function Loading() {
return <p>Loading user...</p>;
}
const routes = [
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{
path: "users/:id",
element: <UserProfile />,
loader: userLoader,
errorElement: <UserErrorPage />,
hydrateFallbackElement: <Loading />,
},
],
},
];
describe("with a HydrateFallback", () => {
it("shows the fallback immediately, then the loaded user, with no warning", async () => {
const router = createMemoryRouter(routes, { initialEntries: ["/users/2"] });
render(<RouterProvider router={router} />);
console.error(screen.getByRole("paragraph").textContent);
await waitFor(() => expect(screen.getByText("User 2: Grace")).toBeInTheDocument());
console.error(screen.getByRole("paragraph").textContent);
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-route-loaders-and-error-elements-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/loaders/HydrateFallbackFixed.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/loaders/HydrateFallbackFixed.test.tsx > with a HydrateFallback > shows the fallback immediately, then the loaded user, with no warning
loader ran for users/2
Loading user...
stderr | src/loaders/HydrateFallbackFixed.test.tsx > with a HydrateFallback > shows the fallback immediately, then the loaded user, with no warning
User 2: Grace
✓ src/loaders/HydrateFallbackFixed.test.tsx > with a HydrateFallback > shows the fallback immediately, then the loaded user, with no warning 231ms
Test Files 1 passed (1)
Tests 1 passed (1)
No warning this time, and Loading user... is what the very first render actually shows — read straight from the DOM immediately after render, before the loader has had any chance to resolve. Once it does, the same route’s normal element takes over and shows User 2: Grace, exactly as it did without the fallback. hydrateFallbackElement only fills the one gap between a route matching and its loader settling on the first, direct load of that route; a HydrateFallback component works the same way (Practice 3).
Practice
1. Add a Link and route for Ada (id: "1", already in users), and confirm the loader logs for her id too when clicked.
Solution
// src/loaders/practice/AppWithAdaLink.tsx
import { Outlet, Link } from "react-router";
export function Layout() {
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/users/1">Ada</Link>
<Link to="/users/2">Grace</Link>
</nav>
<Outlet />
</div>
);
}
// src/loaders/practice/AdaLoader.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Home, UserProfile, UserErrorPage, userLoader } from "../App.js";
import { Layout } from "./AppWithAdaLink.js";
const routes = [
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: "users/:id", element: <UserProfile />, loader: userLoader, errorElement: <UserErrorPage /> },
],
},
];
describe("Ada link", () => {
it("runs the loader for id 1 and renders her profile", async () => {
const user = userEvent.setup();
const router = createMemoryRouter(routes, { initialEntries: ["/"] });
render(<RouterProvider router={router} />);
await user.click(screen.getByRole("link", { name: "Ada" }));
expect(screen.getByText("User 1: Ada")).toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-route-loaders-and-error-elements-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/loaders/practice/AdaLoader.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/loaders/practice/AdaLoader.test.tsx > Ada link > runs the loader for id 1 and renders her profile
loader ran for users/1
✓ src/loaders/practice/AdaLoader.test.tsx > Ada link > runs the loader for id 1 and renders her profile 232ms
Test Files 1 passed (1)
Tests 1 passed (1)
loader ran for users/1 — the same userLoader, called with whatever :id actually matched, regardless of which link pointed there.
2. Remove errorElement from the users/:id route and put it on the root route instead. Confirm the 404 now behaves like the previous lesson’s unmatched URL: the nav disappears too.
Solution
// src/loaders/practice/RootErrorElement.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Layout, Home, UserProfile, UserErrorPage, userLoader } from "../App.js";
const routes = [
{
path: "/",
element: <Layout />,
errorElement: <UserErrorPage />,
children: [
{ index: true, element: <Home /> },
{ path: "users/:id", element: <UserProfile />, loader: userLoader },
],
},
];
describe("errorElement on the root instead of the child", () => {
it("replaces the whole tree, nav included", async () => {
const user = userEvent.setup();
const router = createMemoryRouter(routes, { initialEntries: ["/"] });
render(<RouterProvider router={router} />);
await user.click(screen.getByRole("link", { name: "Nobody" }));
expect(screen.getByText("404 No such user")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Home" })).not.toBeInTheDocument();
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-route-loaders-and-error-elements-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/loaders/practice/RootErrorElement.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/loaders/practice/RootErrorElement.test.tsx > errorElement on the root instead of the child > replaces the whole tree, nav included
loader ran for users/99
✓ src/loaders/practice/RootErrorElement.test.tsx > errorElement on the root instead of the child > replaces the whole tree, nav included 331ms
Test Files 1 passed (1)
Tests 1 passed (1)
Same 404 No such user text, same thrown data, but no Home link this time: with no errorElement on users/:id itself, the error bubbles up to the nearest ancestor that has one — the root — and replaces Layout along with everything it rendered.
3. Replace hydrateFallbackElement with the component form, HydrateFallback, and confirm it silences the same warning.
Solution
// src/loaders/practice/HydrateFallbackComponent.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { Layout, Home, UserProfile, UserErrorPage, userLoader } from "../App.js";
function Loading() {
return <p>Loading user...</p>;
}
const routes = [
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{
path: "users/:id",
element: <UserProfile />,
loader: userLoader,
errorElement: <UserErrorPage />,
HydrateFallback: Loading,
},
],
},
];
describe("HydrateFallback as a component", () => {
it("also silences the warning", async () => {
const router = createMemoryRouter(routes, { initialEntries: ["/users/2"] });
render(<RouterProvider router={router} />);
console.error(screen.getByRole("paragraph").textContent);
await waitFor(() => expect(screen.getByText("User 2: Grace")).toBeInTheDocument());
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-route-loaders-and-error-elements-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/loaders/practice/HydrateFallbackComponent.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/loaders/practice/HydrateFallbackComponent.test.tsx > HydrateFallback as a component > also silences the warning
loader ran for users/2
Loading user...
✓ src/loaders/practice/HydrateFallbackComponent.test.tsx > HydrateFallback as a component > also silences the warning 316ms
Test Files 1 passed (1)
Tests 1 passed (1)
Same result as hydrateFallbackElement — React Router accepts either a ready-made element or a component it renders itself; HydrateFallback: Loading is the component form, with no JSX needed at the route-definition site.
4. Change the loader to throw new Error("No such user (plain throw)") instead of data(...), and confirm isRouteErrorResponse returns false for it.
Solution
// src/loaders/practice/PlainThrow.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { isRouteErrorResponse, useRouteError, type LoaderFunctionArgs } from "react-router";
import { Layout, Home, UserProfile, users } from "../App.js";
async function throwingLoader({ params }: LoaderFunctionArgs) {
const user = params.id ? users[params.id] : undefined;
if (!user) {
throw new Error("No such user (plain throw)");
}
return user;
}
function Loading() {
return <p>Loading...</p>;
}
function PlainErrorPage() {
const error = useRouteError();
console.error("isRouteErrorResponse:", isRouteErrorResponse(error));
console.error("error instanceof Error:", error instanceof Error);
console.error("error.message:", error instanceof Error ? error.message : String(error));
return <p>Something went wrong</p>;
}
const routes = [
{
path: "/",
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{
path: "users/:id",
element: <UserProfile />,
loader: throwingLoader,
errorElement: <PlainErrorPage />,
hydrateFallbackElement: <Loading />,
},
],
},
];
describe("a plain throw instead of data()", () => {
it("is not a route error response", async () => {
const router = createMemoryRouter(routes, { initialEntries: ["/users/99"] });
render(<RouterProvider router={router} />);
await waitFor(() => expect(screen.getByText("Something went wrong")).toBeInTheDocument());
});
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-route-loaders-and-error-elements-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/loaders/practice/PlainThrow.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"
RUN v5.0.5 /root/react-app
stderr | src/loaders/practice/PlainThrow.test.tsx > a plain throw instead of data() > is not a route error response
isRouteErrorResponse: false
error instanceof Error: true
error.message: No such user (plain throw)
✓ src/loaders/practice/PlainThrow.test.tsx > a plain throw instead of data() > is not a route error response 87ms
Test Files 1 passed (1)
Tests 1 passed (1)
isRouteErrorResponse: false — the same errorElement still catches a plain Error thrown from a loader, but without data()’s wrapping there’s no .status or .data to read, just an ordinary Error instance with its own .message.