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.

ErrorBoundary

Any route module can export ErrorBoundary. If a loader, action, component, meta, links, or headers throws, the router renders the nearest boundary instead of a blank page. In Framework mode the error arrives as a prop, so no hook is needed.

// app/root.tsx
import { isRouteErrorResponse } from "react-router";
import type { Route } from "./+types/root";

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

Handle all three cases. A route error response, a real Error, and a thrown value that is neither, because someone somewhere threw a string.

Bubbling

The error renders in the closest ancestor route that exports ErrorBoundary, and everything above it keeps rendering.

- A boundary on a leaf route keeps the layout, nav, and sidebar alive. - With no boundary on the route, the error walks up to the parent, then to the root. - A root boundary replaces the whole page, so always ship one. - An error thrown inside a boundary itself bubbles to the next one up.

Put a boundary on the deepest route that can fail on its own. A detail pane that 404s should not blank out the list next to it.

Root Boundary and the Document

If the root boundary replaces the root component, it also replaces whatever the root component rendered, including the document tags. Put them in a Layout export, which wraps both the app and the boundary.

// app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "react-router";

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

Without Layout, an error page rendered on the server has no <Scripts /> and never hydrates, which means no client routing after the user hits an error. This is the single most common Framework mode error handling bug.

Throwing on Purpose

Do not throw for control flow, but 404 is a real exception.

import { data, redirect } from "react-router";

export async function loader({ params }: Route.LoaderArgs) {
  const record = await db.get(params.id);
  if (!record) throw data("Record Not Found", { status: 404 });
  if (record.movedTo) throw redirect(record.movedTo, 301);
  return record;
}
ThrownisRouteErrorResponse(error)Boundary sees
data("msg", { status: 404 })truestatus, statusText, data
new Response(null, { status: 403 })trueSame, data is the body
new Error("boom")falsemessage, stack
"boom"falseThe raw value
redirect("/login")Not an errorNavigates, no boundary

404s

Two different 404s exist and both need handling.

// app/routes.ts, a catch all for URLs with no route
route("*", "routes/not-found.tsx"),
// app/routes/not-found.tsx
export function loader() {
  throw data("Not Found", { status: 404 });
}

The splat route catches unknown URLs. A thrown 404 from a loader catches a known URL with a missing record. Both must send an actual 404 status, not a 200 with a "not found" body, or crawlers keep indexing the page.

Production Behavior

Server side stack traces are stripped in production builds, so error.message becomes a generic string and error.stack is undefined. That is deliberate, since messages can leak query text or paths.

- Log the real error server side in the loader or action, or through entry.server.tsx's handleError. - Route error responses keep their status, statusText, and data, so put anything the user should read into a thrown data(). - Client thrown errors are not stripped, since that code already ships to the browser. - Render the stack only behind an import.meta.env.DEV check.

Data and Declarative Modes

// Data mode: ErrorBoundary or errorElement per route object
const router = createBrowserRouter([
  {
    path: "/",
    Component: Root,
    ErrorBoundary: RootBoundary,   // or errorElement: <RootBoundary />
    children: [{ path: "teams/:id", Component: Team, loader }],
  },
]);

function RootBoundary() {
  const error = useRouteError();   // props are framework mode only
  return isRouteErrorResponse(error) ? <h1>{error.status}</h1> : <h1>Error</h1>;
}

Declarative mode has no router level boundary at all. Wrap the tree in a plain React error boundary and handle fetch failures yourself.