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.
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 no02Place 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).
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.
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.
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
Make Try again work
In the first example, change
<StockTicker prices={{}} />soAppkeepspricesin state (start with{}), and make the fallback'sresetalso setpricesto{ nifty: 22450.5 }. Predict what appears after clicking Try again. A reset only helps if whatever caused the error has changed. - 2
Remove the widget boundary
Delete the
ErrorBoundaryaroundStockTicker(keep the ticker). Predict the screen and the log line, then run. With no boundary,onUncaughtErrorfires and React unmounts the whole app, heading and Likes button included. - 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
`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
Console
Nothing logged yet.Expected console output
report: caught by a boundary - Cannot read properties of undefined (reading 'toFixed')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
Console
Nothing logged yet.Expected console output
onCaughtError: Server said 500
Screen: Save (handler) Couldn't save: Server said 500 | Boundary caught: Server said 500The 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
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// 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.
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.
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 withconsole.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-boundarygive you a function-component API:<ErrorBoundary FallbackComponent={...} onReset={...} resetKeys={[userId]}>and auseErrorBoundary()hook whoseshowBoundary(error)sends handler errors to the boundary. - ▸
TanStack Query's
throwOnErroroption (anduseSuspenseQuery, which always throws) lets data errors reach the nearest boundary;QueryErrorResetBoundaryresets 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
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
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
Boundaries only catch errors during rendering (and in lifecycle methods and effects). Errors in event handlers and plain
asynccode 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 tostartTransition,useActionStateor a formaction, 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
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, witherrorInfo.componentStack, to an error-tracking service such as Sentry (Topic 23.5). Handler and async errors you catch yourself should go through the samereportError()function. Report once: logging the same error in the boundary, the handler and the root makes counts meaningless. - 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-liveregion so screen readers announce them, and they must stay long enough to read. - 6
Retries help only with temporary failures: network drops,
503 Service Unavailable,429 Too Many Requests. Retrying a400or403gives 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
How would you design error handling for a large React app?
Where would you place error boundaries, and why not around every component?
Which errors do error boundaries not catch, and how do you handle those?
When should a failed request be retried, and how?
Practice
Write a useAsyncAction(fn) hook that returns { run, error, pending }, catches errors from fn, reports them with reportError, and stores a user-facing message.
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
onCaughtErrorandonUncaughtError.I can write user-facing error messages and accessible toasts.
I can retry only temporary failures with exponential backoff.