React Router Cheatsheet

Error Handling

Use this React Router reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Error Boundaries

Any error thrown in a loader, an action, or during render bubbles to the nearest errorElement (data mode) or ErrorBoundary export (framework mode). The rest of the page keeps working, because only that route's subtree is replaced.

// Data mode
{
  path: "/",
  element: <Layout />,
  errorElement: <RootError />,
  children: [
    { path: "users/:id", element: <User />, loader: userLoader,
      errorElement: <UserError /> },   // catches only this route's errors
  ],
}
// Framework mode
import { isRouteErrorResponse } from "react-router";
import type { Route } from "./+types/user";

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    );
  }
  if (error instanceof Error) {
    return <div><h1>Error</h1><p>{error.message}</p></div>;
  }
  return <h1>Unknown error</h1>;
}

Put an errorElement on your root route at minimum. Without one, React Router renders its own default error page, which is fine in development and unacceptable in production.

Reading the Error

import { useRouteError, isRouteErrorResponse } from "react-router";

function ErrorPage() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    // A thrown Response: expected, with a status
    if (error.status === 404) return <NotFound />;
    if (error.status === 401) return <Login />;
    if (error.status === 403) return <Forbidden />;
    return <p>{error.status} {error.statusText}</p>;
  }

  // An unexpected Error: a bug
  const message = error instanceof Error ? error.message : "Unknown error";
  return <p role="alert">{message}</p>;
}

The distinction is worth designing around: throw a Response for expected conditions (not found, unauthorized, validation that should stop the request), and let real Errors represent bugs. isRouteErrorResponse is how the boundary tells them apart.

// Expected: a 404 page, not a crash
throw new Response("Not Found", { status: 404 });

// Expected, with data for the boundary
throw new Response(JSON.stringify({ id }), {
  status: 404,
  headers: { "Content-Type": "application/json" },
});

// Unexpected: report this
throw new Error("Database connection failed");

404 Handling

Two different 404s, handled differently:

// 1. No route matches the URL at all
{ path: "*", element: <NotFound /> }

// 2. The route matches, but the record does not exist
loader: async ({ params }) => {
  const user = await getUser(params.id);
  if (!user) throw new Response("Not Found", { status: 404 });
  return { user };
}

The splat route handles the first, and the boundary handles the second. In SSR both should produce a real 404 status, not a 200 with 404 content, or search engines will index the error page.

Error Recovery

Give the user a way out. An error boundary that only shows a message is a dead end.

function RouteError() {
  const error = useRouteError();
  const navigate = useNavigate();
  const revalidator = useRevalidator();

  return (
    <div role="alert">
      <h2>Something went wrong</h2>
      <p>{isRouteErrorResponse(error) ? error.statusText : "Unexpected error"}</p>
      <button onClick={() => revalidator.revalidate()}>Try again</button>
      <button onClick={() => navigate("/")}>Go home</button>
    </div>
  );
}

Reporting Errors

function RootError() {
  const error = useRouteError();
  useEffect(() => {
    if (!isRouteErrorResponse(error)) {
      reportToSentry(error);       // only report real bugs, not 404s
    }
  }, [error]);
  return <ErrorUI />;
}
// Framework mode: a server-side hook for logging
// app/entry.server.tsx
export function handleError(error: unknown, { request }: { request: Request }) {
  if (!request.signal.aborted) {
    console.error(error);
    reportToSentry(error);
  }
}

Skipping aborted requests is important, since a user navigating away mid-load produces an abort that is not a bug and will otherwise flood your error tracker.

Hydration Fallback

During SSR hydration a route may briefly have no data. hydrateFallbackElement covers that gap.

{
  path: "/",
  element: <Root />,
  loader: rootLoader,
  hydrateFallbackElement: <Skeleton />,
  errorElement: <RootError />,
}

Not Found vs Error, a Summary

ConditionDoBoundary sees
URL matches nothing{ path: "*" } routeNothing, it is a normal render
Record missingthrow new Response(…, { status: 404 })isRouteErrorResponse, status 404
Not logged inthrow redirect("/login")Nothing, the router redirects
Logged in, wrong rolethrow new Response(…, { status: 403 })isRouteErrorResponse, status 403
Invalid form inputreturn data({ errors }, { status: 400 })Nothing, useActionData sees it
BugLet the Error throwAn Error instance