In React Router 7's data router, what happens when a loader throws, and how does the error element tell a deliberate 404 from a crash?
answer
- route property, not try/catch
- nearest boundary up the tree
- one hook reads the thrown value
- status-bearing response versus Error
basics
~20 sIn a React Router 7 data router, a thrown loader error renders the nearest route errorElement or ErrorBoundary in place of that route's element. The boundary reads it with useRouteError; isRouteErrorResponse is true for a thrown data(..., { status: 404 }).
solid answer
~40 sWhen a loader throws in React Router 7, the router records the error and renders the `errorElement` (or `ErrorBoundary`) of the nearest route at or above the failing one, in place of that route's element; parent layouts above it keep rendering. The same boundary also catches render errors and thrown action errors. Inside it, `useRouteError()` returns what was thrown, and `isRouteErrorResponse(error)` separates deliberate outcomes from crashes: `throw data("Not found", { status: 404 })` produces an error response with `status`, `statusText` and `data`, while `throw new Error(...)` gives you the `Error` itself. I put a boundary on the root route so nothing reaches the built-in default screen, add local ones where a failure should stay contained, and return rather than throw validation errors so the form keeps rendering.
code
tsx · 18 linesimport { isRouteErrorResponse, useRouteError } from "react-router";
export function PostError() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<section>
<h1>{error.status === 404 ? "Post not found" : `Error ${error.status}`}</h1>
<p>{String(error.data)}</p>
</section>
);
}
if (error instanceof Error) {
return <p role="alert">Something went wrong: {error.message}</p>;
}
return <p role="alert">Unknown error</p>;
}go deeper
Recall that routes carry errorElement or ErrorBoundary, that the boundary reads the error with useRouteError, and that a root boundary is a must.
Explain the walk up to the nearest boundary, what it replaces, and how isRouteErrorResponse separates a thrown status response from a crash.
Show judgment about boundary placement so failures stay local, and about throwing versus returning, especially for action validation errors.
Frame a team convention for error responses versus exceptions and how boundaries map to the product's failure domains.
## Where a thrown loader error goes In a **React Router 7 data router**, every route object can carry an error UI: either **`errorElement`** (a React element) or **`ErrorBoundary`** (a component). When a loader throws or returns a rejected promise, the router does not let the error escape as an unhandled rejection. Instead: 1. It records the error against the route whose loader failed. 2. It looks for the **nearest route with an error boundary**, starting at the failing route itself and walking **up** through its ancestors. 3. That route's `errorElement` renders **in place of that route's element**. Everything above it (parent layouts, navigation bars) keeps rendering normally. 4. Data loaded for routes **below** that boundary is never shown, because the subtree it would fill has been replaced by the error UI. The same boundaries also catch errors **thrown while rendering** a route's element, and errors thrown by an **action**. For an action error, the router does not call the loaders of the boundary route or any route below it, since that part of the page is about to be replaced by the error UI. ## Reading the error inside the boundary Inside the error UI, **`useRouteError()`** returns whatever was thrown. It is typed `unknown` because a loader can throw anything. The helper **`isRouteErrorResponse(error)`** tells the two big families apart: | What the loader did | What `useRouteError()` returns | `isRouteErrorResponse` | |---|---|---| | `throw data("Not found", { status: 404 })` | an error response with `status`, `statusText` and `data` | `true` | | `throw new Error("db timeout")` | the `Error` instance itself | `false` | | an exception while rendering the element | the `Error` instance | `false` | An **error response** is a deliberate, expected outcome with an HTTP-like status: the record does not exist, the user may not see it. A plain `Error` is a **crash**: something broke. A good boundary branches on this: - `isRouteErrorResponse(error)` true: render a friendly "404 - post not found" using `error.status` and `error.data`. - `error instanceof Error`: render a generic "something went wrong", log `error.message`, offer a retry. - anything else: render a fallback message, since non-`Error` values can be thrown too. ## Placing boundaries - Put one at the **root route** so failures do not fall through to the router's built-in fallback screen. - Add boundaries on **leaf or section routes** where a failure should stay local: a broken widget route should not wipe out the dashboard shell above it. - Because the error replaces the element of the route that owns the boundary, a boundary on a **layout** route replaces the layout too, including its `<Outlet>`. Put it one level lower if the layout should survive. If no route has a boundary, React Router renders its own default error screen, headed "Unexpected Application Error!", and in development it adds a hint telling you to provide your own `errorElement` or `ErrorBoundary`. That screen is a signal, not a UI to ship. ## Throwing versus returning Not every failure should reach a boundary: - **Throw** when the route cannot render at all: a missing record (`throw data(..., { status: 404 })`), a failed dependency. - **Return** when the page should keep rendering and show the problem inline, typically **form validation** in an action: `return data({ errors }, { status: 400 })`, then read it with `useActionData()` next to the fields. Returning a 4xx from an action also stops the default loader revalidation. ## Retrying after a failure An error boundary is not a dead end. Once the underlying problem clears, a fresh navigation to the same route re-runs its loaders and, if they succeed, the normal element renders again. A boundary can offer that directly with a `<Link>` back to the current URL or a button that calls `useRevalidator().revalidate()`, which re-runs the active loaders without leaving the page. ## Version notes - v6 code often threw `json({ message }, { status: 404 })`; `json` was **removed in v7**. Use `data()` from `react-router` for the same result. - `errorElement` and `ErrorBoundary` are two spellings of the same slot on the route object; use one per route. - A streamed promise rendered through `<Await>` has its own `errorElement` prop, which is separate from the route-level one and only covers that promise.
- In React Router 7, if a child route's loader throws and only the root route has an ErrorBoundary, what does the user see?The root route's `ErrorBoundary` replaces the root route's element, so if the app shell (header, navigation) lives in that element, the shell disappears along with the page. To keep it, add a boundary to a route below the layout, such as the section route or the child route itself.
- In React Router 7, why is useRouteError typed unknown rather than Error?Because a loader, action or render can throw any value: an `Error`, an error response created with `data()`, a string or an object. The boundary has to narrow it itself, with `isRouteErrorResponse` for status responses and `instanceof Error` for exceptions.
saying these in an interview costs you the question
- Thinks each loader must catch its own errors and return a flag for the component.
- Believes the error renders inside the parent's Outlet while the parent keeps its element.
- Assumes useRouteError always returns an Error instance.
- Throws validation errors from an action and loses the form on screen.
- Still throws json(...) in v7 code, although json was removed.