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; }
| Thrown | isRouteErrorResponse(error) | Boundary sees |
|---|---|---|
data("msg", { status: 404 }) | true | status, statusText, data |
new Response(null, { status: 403 }) | true | Same, data is the body |
new Error("boom") | false | message, stack |
"boom" | false | The raw value |
redirect("/login") | Not an error | Navigates, 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.