Skip to main content
React · lesson 22 of 22
React advanced Lesson 22 of 22

Route 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

react react-router loaders vitest

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.

Frequently Asked Questions

When does a React Router loader run, relative to the route's component?
Before. The router calls the matched route's loader and waits for it to resolve, and only then renders that route's element — the component reads the result with useLoaderData instead of fetching in an effect after its own first render. That ordering is also why an unresolved loader needs something to show in the meantime, which is what HydrateFallback is for.
Why does this lesson's 404 keep the nav bar, when the previous lesson's unmatched-URL 404 replaced the whole page?
errorElement is scoped to whichever route it's attached to. This loader's errorElement sits on the users/:id route itself, so React Router only replaces that route's own output inside the parent's Outlet — the parent Layout, nav included, keeps rendering normally. The previous lesson's errorElement sat on the root route, so an error there replaces the entire tree, nav included, because there's no parent left to keep anything else mounted.
What does data("message", { status: 404 }) do that throwing a plain Error doesn't?
It wraps the value in a shape React Router recognizes as a route error response: isRouteErrorResponse(error) returns true, and error.status and error.data carry the status code and message straight to whatever reads useRouteError. A plain throw new Error(...) reaches the same errorElement, but isRouteErrorResponse returns false for it and there's no .status or .data — just the ordinary Error instance, as the last Practice task confirms.
Why does the HydrateFallback warning only show up sometimes?
It's specific to a route's very first render matching the current URL, before its loader has resolved — what React Router calls initial hydration. Clicking a Link to that same route after the app is already mounted is an ordinary client-side navigation, not initial hydration, so no fallback is needed there: the previous route just keeps showing until the new one's data is ready.
Does fixing the warning change what the page shows once the data has loaded?
No. hydrateFallbackElement (or a HydrateFallback component) only fills the gap between the route matching and its loader resolving on that first render. Once the loader settles, the route's normal element replaces it exactly as it would have without a fallback — the only visible difference is that the gap now shows something instead of nothing, and the warning is gone.