Command Palette

Search for a command to run...

Hectal
PHASE 22Advanced ~29 min· topic 4 of 7

Topic 22.4

An Error Handling Strategy

In one line

Decide in advance where each kind of error goes: expected errors (validation, 404, a refused action) are shown inline or as a toast where they happen, unexpected bugs are caught by error boundaries placed at the root, route and widget levels, every error is reported once from a central place, and only temporary failures are retried, with backoff.

Think of it like this

A large building's fire plan. Small problems are handled where they happen (a fire blanket in the kitchen). Bigger ones close one floor with fire doors so the rest of the building keeps working. Every alarm is reported to one control room. And nobody keeps re-entering a burning room: you retry only when it's safe. An app needs the same plan: local handling, error boundaries as fire doors, one place that reports, and careful retries.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Expected error
A failure that is part of normal use: invalid input, not found, not allowed, offline. You design a screen for it.
Unexpected error
A bug, such as reading a property of undefined. You contain it with a boundary and report it.
Error boundary
A component that catches errors thrown while rendering its children and shows a fallback instead.
Fallback UI
What a boundary shows instead of the broken part: a message and often a Try again button.
Error tracking
A service (Sentry, Bugsnag, Datadog…) that collects errors from users' browsers with stack traces and context.
Toast
A short message that appears in a corner for a few seconds, used for results of actions.
Exponential backoff
Waiting twice as long after each failed attempt before retrying: 1 s, 2 s, 4 s…
Jitter
A small random amount added to retry delays so many clients don't retry at the same moment.
Idempotent
Safe to repeat: doing it twice has the same effect as once (reading data, setting a value).

Step by step

01Sort the errors before writing any code

For each feature, list what can go wrong and decide where each case is shown. This table is the strategy; the code only implements it.

error plan for a dashboardwhole filebash
What happens                     Kind         Where it's shown                 Reported?  Retried?
Email field invalid              expected     under the field                  no         no
Page /orders/999 not found       expected     route's not-found screen         no         no
Save failed: 403 forbidden       expected     toast: "you can't do that"       no         no
Save failed: 503 / offline       temporary    toast after retries fail         yes        yes, backoff
Stock widget throws in render    bug          widget boundary: "unavailable"   yes        no (Try again button)
Route component throws           bug          route boundary, nav still works  yes        no
Anything else uncaught           bug          root boundary, reload button     yes        no

02Place boundaries like fire doors

Three levels cover most apps. The root boundary is the last resort; route boundaries keep the layout alive; widget boundaries keep independent parts alive. With React Router, errorElement on a route is a route-level boundary (Topic 13.5); in Next.js it's an error.js file per route segment (Phase 20).

Place boundaries like fire doorsdiagram
Rendering diagram…

03Report from one place

Every boundary-caught error flows to onCaughtError, every crash to onUncaughtError. Errors you catch in handlers call the same reportError. One function decides what to send, adds the user id and app version, and strips personal data.

src/main.jsxwhole filejsx
import { createRoot } from "react-dom/client";
import { reportError } from "@/shared/lib/report";

createRoot(document.getElementById("root"), {
  onCaughtError: (error, info) => reportError(error, { componentStack: info.componentStack, handled: true }),
  onUncaughtError: (error, info) => reportError(error, { componentStack: info.componentStack, handled: false }),
  onRecoverableError: (error, info) => reportError(error, { componentStack: info.componentStack, recoverable: true }),
}).render(<App />);

04Handle handler and async errors where they happen

An event handler that awaits a request needs a try/catch. Decide there: show an inline message, show a toast, or (inside an Action in React 19) let it reach a boundary.

Handle handler and async errors where they happendiagram
Rendering diagram…

05Retry only what can succeed later

Mark errors as retryable when you create them (network, 5xx, 429), back off between tries, cap the number of attempts, and only then tell the user. For mutations, retry only if the server makes them idempotent.

Try it yourself

  1. 1

    Make Try again work

    In the first example, change <StockTicker prices={{}} /> so App keeps prices in state (start with {}), and make the fallback's reset also set prices to { nifty: 22450.5 }. Predict what appears after clicking Try again. A reset only helps if whatever caused the error has changed.

  2. 2

    Remove the widget boundary

    Delete the ErrorBoundary around StockTicker (keep the ticker). Predict the screen and the log line, then run. With no boundary, onUncaughtError fires and React unmounts the whole app, heading and Likes button included.

  3. 3

    Change the retry budget

    In the retry example, call withRetry(request, { retries: 1 }). Predict the log: how many attempts happen before the order gives up?

Code & diagrams

A widget boundary keeps the dashboard alive, and one place reports New tab

`StockTicker` reads `prices.nifty.toFixed` on an empty object and throws. Its boundary shows a small fallback; the heading and the Likes button keep working. Because `onCaughtError` is set, React hands the error to our reporter instead of printing its default console error.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

report: caught by a boundary - Cannot read properties of undefined (reading 'toFixed')
Which errors reach a boundary: a handler versus an Action React 19+ New tab

Both buttons hit the same failing request. The handler catches it itself and shows a message; its boundary never hears about it. The Action rejects inside `startTransition`, so React 19 throws it to the nearest boundary and calls `onCaughtError`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

onCaughtError: Server said 500
Screen: Save (handler) Couldn't save: Server said 500 | Boundary caught: Server said 500
Retries with backoff, then a toast New tab

The 503s are retried after 100 ms and 200 ms and the third attempt succeeds. The 403 isn't retryable, so it fails at once with a message that says what it means. Toasts sit in an `aria-live` region so screen readers announce them. A real toast would also disappear after a few seconds and have a close button.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

attempt 1 failed with HTTP 503 - retrying in 100 ms
attempt 2 failed with HTTP 503 - retrying in 200 ms
Order succeeded
Cancel gave up: HTTP 403
Sending errors to a tracking service (Sentry React SDK)jsx
// src/main.jsx
import * as Sentry from "@sentry/react";
import { createRoot } from "react-dom/client";

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  release: import.meta.env.VITE_APP_VERSION, // match uploaded source maps (Topic 23.5)
  sendDefaultPii: false,                     // don't send IPs, cookies or user data by default
});

createRoot(document.getElementById("root"), {
  onUncaughtError: Sentry.reactErrorHandler(),
  onCaughtError: Sentry.reactErrorHandler(),
  onRecoverableError: Sentry.reactErrorHandler(),
}).render(<App />);

// Errors you catch yourself go to the same place:
// catch (err) { Sentry.captureException(err); toast("error", "Couldn't save."); }

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Expecting a boundary to catch a click handler error

Inside the widget, add <button onClick={() => { throw new Error("Click failed"); }}>Refresh</button> and click it, expecting the widget's fallback.

terminal
$ Console
── what you'll see ──
Uncaught Error: Click failed

Break #2

Retrying a request that can never succeed

Mark every error as retryable and set retries: 5, then submit a form with an invalid email that the server rejects with 400 Bad Request.

terminal
$ Network panel
── what you'll see ──
POST /api/signup 400
POST /api/signup 400
POST /api/signup 400
POST /api/signup 400
POST /api/signup 400
POST /api/signup 400 (about 30 seconds later, then a generic "try again later" toast)

Myth vs fact

Myth

One error boundary at the root is enough.

Fact

A single root boundary turns every small bug into a full-page crash. Add route-level and widget-level boundaries so a broken part fails alone.

Myth

Error boundaries catch all errors in their subtree.

Fact

They catch errors in rendering, lifecycle methods and effects (and, in React 19, in Actions). Event handlers, timers and plain async code need their own try/catch.

Myth

Retrying is always safe.

Fact

Retrying a non-idempotent request (a payment, "send message") can perform it twice. Retry only safe requests, or use server-side idempotency keys.

Myth

Toasts are the right place for every error.

Fact

Toasts disappear. Errors the user must act on (an invalid field, a failed payment) belong next to their cause and stay until fixed.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Without onCaughtError, React logs every caught error with console.error (the live example shows that setting the option replaces this default). Make sure your handler still logs to the console in development, or you'll lose the stack trace while debugging.

  • ▸

    Libraries such as react-error-boundary give you a function-component API: <ErrorBoundary FallbackComponent={...} onReset={...} resetKeys={[userId]}> and a useErrorBoundary() hook whose showBoundary(error) sends handler errors to the boundary.

  • ▸

    TanStack Query's throwOnError option (and useSuspenseQuery, which always throws) lets data errors reach the nearest boundary; QueryErrorResetBoundary resets the failed queries when the boundary resets.

  • ▸

    Rate-limit and de-duplicate reports on the client: a render loop that throws on every frame can send thousands of identical events and exhaust your error-tracking quota in minutes.

Remember this

  1. 1

    Start by sorting errors into two kinds. Expected errors are part of normal life: a form field is invalid, a page doesn't exist (404), the user isn't allowed (403), the network dropped. You design screens for them. Unexpected errors are bugs: Cannot read properties of undefined, a component that throws. You can't design for each one, so you contain them and report them. Mixing the two up is the root of most bad error UX: crash screens for a typo in an email field, or silent failures for real bugs.

  2. 2

    An error boundary (Topic 14.5) catches errors thrown while rendering its children and shows a fallback. Where you place boundaries decides how much of the screen a bug takes down. A good default has three levels: one at the root (last resort: a full-page "Something went wrong" with a reload button), one per route (the header and navigation keep working, only the page area shows the error), and one around independent widgets (a broken stock ticker shows "unavailable" while the rest of the dashboard works). Don't wrap every small component: too many boundaries hide bugs and clutter the UI with half-broken pieces.

  3. 3

    Boundaries only catch errors during rendering (and in lifecycle methods and effects). Errors in event handlers and plain async code never reach them, so handle those where they happen: try/catch, then show an inline message or a toast. In React 19 there's a useful exception: an error thrown inside an Action (a function passed to startTransition, useActionState or a form action, Topics 19.2 to 19.4) is sent to the nearest error boundary, so async failures can use the same fallback UI as render errors.

  4. 4

    Logging should happen in one place. React 19's createRoot(container, { onCaughtError, onUncaughtError, onRecoverableError }) callbacks receive every error a boundary caught, every error no boundary caught (the app has unmounted) and every error React recovered from (for example hydration mismatches). Send them, with errorInfo.componentStack, to an error-tracking service such as Sentry (Topic 23.5). Handler and async errors you catch yourself should go through the same reportError() function. Report once: logging the same error in the boundary, the handler and the root makes counts meaningless.

  5. 5

    User-facing messages say what happened and what to do next, in plain words: "We couldn't save your note. Check your connection and try again." Never show stack traces or raw server messages (they confuse users and can leak internal details). A toast (a small message that pops up and disappears) suits results of actions the user can ignore or retry ("Saved", "Couldn't send, retrying"). Errors the user must fix belong next to the cause (under the field), not in a toast that vanishes. Toasts need an aria-live region so screen readers announce them, and they must stay long enough to read.

  6. 6

    Retries help only with temporary failures: network drops, 503 Service Unavailable, 429 Too Many Requests. Retrying a 400 or 403 gives the same answer, and retrying a non-idempotent request (one that isn't safe to repeat, such as "charge the card") can do the thing twice unless the server supports an idempotency key. Wait longer after each failure (exponential backoff: 1 s, 2 s, 4 s…) and add random jitter so thousands of clients don't retry at the same instant. TanStack Query does this for you: queries retry 3 times in the browser by default with a delay that doubles up to 30 seconds, and mutations don't retry unless you ask (Topic 14.6).

Explain it without notes

01

How would you design error handling for a large React app?

02

Where would you place error boundaries, and why not around every component?

03

Which errors do error boundaries not catch, and how do you handle those?

04

When should a failed request be retried, and how?

Practice

01

Write a useAsyncAction(fn) hook that returns { run, error, pending }, catches errors from fn, reports them with reportError, and stores a user-facing message.

02

Add a route-level boundary to a React Router 8 data router so that an error in /orders shows a message inside the layout while the navigation stays.

Trade-offs

  • ↔

    More boundaries mean smaller blast radius but more fallback states to design and test; place them where the product can genuinely say "this part is unavailable".

  • ↔

    Automatic retries hide brief outages from users but add delay before a real failure is shown and extra load on a struggling server; cap them and back off.

  • ↔

    Detailed error reports speed up debugging but can capture personal data; scrub inputs and URLs, and follow your privacy policy (Topic 23.5).

Done when you can

  • I can sort errors into expected and unexpected and decide where each is shown.

  • I can place error boundaries at root, route and widget level.

  • I can handle event-handler and async errors, and know when React 19 sends them to a boundary.

  • I can report every error from one place with onCaughtError and onUncaughtError.

  • I can write user-facing error messages and accessible toasts.

  • I can retry only temporary failures with exponential backoff.