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
| Condition | Do | Boundary sees |
|---|---|---|
| URL matches nothing | { path: "*" } route | Nothing, it is a normal render |
| Record missing | throw new Response(…, { status: 404 }) | isRouteErrorResponse, status 404 |
| Not logged in | throw redirect("/login") | Nothing, the router redirects |
| Logged in, wrong role | throw new Response(…, { status: 403 }) | isRouteErrorResponse, status 403 |
| Invalid form input | return data({ errors }, { status: 400 }) | Nothing, useActionData sees it |
| Bug | Let the Error throw | An Error instance |