remix-v2-error-boundaries-review

v2026.09.24

Reviews Remix v2 error-handling code for the unified ErrorBoundary, isRouteErrorResponse narrowing, throw-vs-return, root boundary scaffolding, and v1 holdovers (CatchBoundary, useCatch). Use when reviewing routes that throw or define ErrorBoundary in a Remix v2 codebase.

GitHub
Install command
npx skhub add existential-birds/remix-v2-error-boundaries-review
Markdown
SKILL.md

Remix v2 Error Boundaries Code Review

Targets TypeScript route modules importing from @remix-run/*. No sibling knowledge skill exists for this topic; the canonical mental model is summarized inline below and expanded in references/.

v2 Boundary Model (read first)

Remix v2 unified v1's CatchBoundary + ErrorBoundary into a single ErrorBoundary route-module export. The framework calls it for both thrown Responses (e.g. throw new Response(...), throw json(...)) and thrown runtime errors (loader/action/render exceptions). Inside the boundary you read the value with the useRouteError() hook, then narrow in this order:

  1. isRouteErrorResponse(error) → it was a thrown Response; read error.status, error.statusText, error.data.
  2. error instanceof Error → real runtime error; read error.message.
  3. else → unknown thrown value; render a generic fallback.

The boundary takes no props. CatchBoundary, useCatch, and the future.v2_errorBoundary flag are all gone — finding any of them is a v1 holdover. Errors render the nearest ErrorBoundary and bubble to the root if none exists; the root boundary remounts the whole document, so it must render <Meta />, <Links />, and <Scripts />. Only thrown loader/action results reach the boundary — a return json(...) with a 4xx status is a successful loader, not an error. Server-side runtime errors also flow through an optional entry.server.tsx handleError export (thrown Responses do not).

Quick Reference

Issue TypeReference
Missing route ErrorBoundary, props-on-boundary, narrowing-only instanceof Error, narrowing-only isRouteErrorResponsereferences/boundary-shape.md
Return-instead-of-throw 4xx/5xx, swallowing error.data, throwing strings, missing handleErrorreferences/throw-response.md
Missing root boundary, root boundary without <Meta />/<Links />/<Scripts />, useLoaderData() in root boundaryreferences/root-boundary.md
CatchBoundary export, useCatch import, v2_errorBoundary future flagreferences/v1-holdovers.md

Review Checklist

  • ErrorBoundary declared export function ErrorBoundary() with no props
  • Error read via useRouteError(), not useCatch() and not a prop
  • Narrowing checks isRouteErrorResponse(error) first, then error instanceof Error, then fallback
  • error.data rendered defensively (typed/narrowed before going into JSX)
  • 4xx / 5xx in loaders/actions use throw (not return) for Response / json
  • Routes that can throw export their own ErrorBoundary (don't tear down parents for a widget failure)
  • Root app/root.tsx exports an ErrorBoundary that renders <Meta />, <Links />, and <Scripts />
  • Root boundary uses useRouteLoaderData("root") (not useLoaderData()) when reading root data
  • No CatchBoundary export anywhere; no useCatch import; no future.v2_errorBoundary in remix.config.js
  • entry.server.tsx exports handleError and pipes runtime errors to an error reporter
  • handleError does not assume thrown Responses flow through it (they don't)
  • Thrown values are Response/json/Error instances — never plain strings or POJOs

Valid Patterns (Do NOT Flag)

These are correct Remix v2 usage and must not be reported as issues:

  • Route without ErrorBoundary that intentionally inherits from a parent — Boundaries cascade up. A child route may omit ErrorBoundary so the parent (or root) renders the fallback. Only flag if the route handles user-distinct error UX and a parent boundary cannot.
  • throw new Response(...) or throw json(...) from a loader/action — The canonical way to signal 404/401/403/etc. This is not "using exceptions for control flow"; it is documented v2 contract.
  • Narrowing only with isRouteErrorResponse(error) — Acceptable when the route demonstrably only throws Responses and has no render-time crash risk. Severity is ADVISORY at most; suggest adding an instanceof Error branch for defense-in-depth, do not flag as a bug.
  • ErrorBoundary that does not call useRouteError() — Valid when the boundary renders a static "Something went wrong" fallback intentionally (e.g. marketing pages that don't want to surface error detail).
  • Root ErrorBoundary calling useRouteLoaderData("root") and getting undefined — Documented defensive pattern (root loader may have thrown). Do not flag the undefined handling as "dead code."
  • handleError returning early on request.signal.aborted — Documented noise filter, not a swallowed error.
  • handleError not handling thrown Responses — By framework contract handleError only fires for runtime errors. The absence of Response handling is correct, not a gap.
  • Nested ErrorBoundary returning a bare fragment (no <html> / <body>) — Only the root boundary owns the document. Nested boundaries render inside parent layouts and must not include document tags.

Severity guidance

Use these defaults unless the codebase has documented a different scale:

PatternDefault severity
CatchBoundary export or useCatch import in v2 codebaseBLOCKER (build-breaking or dead code)
Root ErrorBoundary missing <Scripts />BLOCKER (dead-end error page)
ErrorBoundary with ({ error }) v1 prop signatureWARN (silent runtime undefined)
return json(...) for 4xx instead of throwWARN (boundary never fires)
Missing instanceof Error branch on a route with render-crash riskWARN
Missing instanceof Error branch on a Response-only routeADVISORY
useLoaderData() (vs useRouteLoaderData) in root boundaryWARN (latent loop)
Missing handleError in entry.server.tsxADVISORY (observability gap, not a bug)

Hard gates (before writing findings)

Run in order. Do not draft user-facing findings until every gate passes for the batch you are about to report.

  1. Location evidence — Pass: Each issue lists the repo path to the route module (or app/root.tsx, or app/entry.server.tsx) and either a line range or a short verbatim quote from the file you read (not from memory or diff-only guesswork). "The root boundary is wrong" without a path to app/root.tsx is not reportable.

  2. Exemption check — Pass: For each issue, you can state in one line why it is not covered by Valid Patterns (Do NOT Flag). In particular: confirm a missing ErrorBoundary is not a deliberate cascade to a parent boundary; confirm an isRouteErrorResponse-only narrowing is not on a route that demonstrably only throws Responses (downgrade to ADVISORY in that case).

  3. v1-vs-v2 marker check — Pass: Before writing the finding, grep the route module (and the repo at large for cross-cutting issues) for: CatchBoundary, useCatch, v2_errorBoundary, ErrorBoundary({ error, ErrorBoundary({error. If any of these appear, the finding is a v1 holdover (load references/v1-holdovers.md) and must be labeled as such — not as a generic "missing error handling" issue. If none appear, the code is v2-shape and the finding is about v2 correctness.

  4. Protocol — Pass: You completed the Pre-Report Verification Checklist in review-verification-protocol for this review.

Review Questions

  1. Does every route that can throw (loader, action, or render) have an ErrorBoundary at the right level — local where the recovery UI matters, parent/root where cascade is intentional?
  2. Does each ErrorBoundary call useRouteError() (not useCatch(), not props) and narrow isRouteErrorResponse first?
  3. Are 4xx / 5xx control flows using throw (not return) so the boundary actually fires?
  4. Does app/root.tsx export an ErrorBoundary with <Meta />, <Links />, and <Scripts />, and use useRouteLoaderData("root") defensively?
  5. Are there any v1 markers left (CatchBoundary, useCatch, v2_errorBoundary, ({ error }) prop signature)?
  6. Is handleError present in entry.server.tsx for runtime-error observability, with the correct contract (no Response handling)?

Additional Documentation

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

Apache-2.0

Source path

plugins/beagle-react/skills/remix-v2-error-boundaries-review

Default branch

main

Latest commit

d1a7489

Tree SHA

9b1f4af