5 min read
Error Handling
Error boundaries that contain damage, offline states designed rather than discovered, and logs that are actually debuggable.
Principles
Contain failures at a meaningful boundary
One boundary at the app root turns any component error into a blank screen. Boundaries per screen and around each risky widget mean a failing recommendations carousel costs you a carousel, not a session.
Mobile is offline-first whether you designed for it or not
Lifts, tunnels, and rural coverage are normal operating conditions. A spinner that never resolves is the most common way this failure shows up, and it is indistinguishable from a hung app.
Show users what to do, log engineers what happened
A raw stack trace helps nobody in the UI, and 'Something went wrong' helps nobody in your logs. Split the audience: an actionable message on screen, full context in the report.
Never swallow an error silently
An empty `catch` converts a loud failure into a mysterious one. If an error is genuinely safe to ignore, the comment explaining why is mandatory.
Every retry needs a backoff and a ceiling
Immediate infinite retries turn a brief server wobble into a self-inflicted denial of service, and drain the battery while doing it.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Boundary granularity
Avoid
<ErrorBoundary fallback={<BlankScreen />}>
<App />
</ErrorBoundary>
// One bad render anywhere = the whole app is a blank screen.Maximum blast radius. A non-essential widget can take down the entire session.
Prefer
<ErrorBoundary fallback={<AppCrashScreen />}>
<Navigation>
<ErrorBoundary fallback={<ScreenError onRetry={reset} />}>
<ProductScreen>
{/* Non-critical widget fails alone. */}
<ErrorBoundary fallback={null}>
<Recommendations />
</ErrorBoundary>
</ProductScreen>
</ErrorBoundary>
</Navigation>
</ErrorBoundary>Nested boundaries scale the fallback to what actually broke.
Offline as a designed state
Avoid
const { data, isPending } = useProducts();
if (isPending) return <Spinner />;
// No connection → pending forever. Looks like a frozen app.
return <ProductList data={data} />;Offline is rendered as an infinite loading state, which users read as 'broken'.
Prefer
const isConnected = useNetInfo().isConnected;
const { data, isPending, error, refetch } = useProducts();
if (!isConnected && !data) {
return <OfflineState onRetry={refetch} />;
}
if (isPending) return <ProductListSkeleton />;
if (error) return <ErrorState error={error} onRetry={refetch} />;
// Cached data with a clear staleness signal beats an empty screen.
return (
<>
{!isConnected && <OfflineBanner />}
<ProductList data={data} />
</>
);Offline is a first-class state with a way forward, and cached content still renders.
Logging with context
Avoid
try {
await submitOrder(draft);
} catch (e) {
console.log("error"); // useless
showToast("Something went wrong");
}The log has no identifying information, so the report is unactionable and the toast is unhelpful.
Prefer
try {
await submitOrder(draft);
} catch (error) {
// Engineers get everything needed to reproduce it.
reportError(error, {
tags: { feature: "checkout" },
extra: { orderId: draft.id, itemCount: draft.items.length },
});
// Users get a specific, actionable message.
showToast(
isNetworkError(error)
? "You appear to be offline. Your order was saved as a draft."
: "We couldn't place your order. Please try again.",
);
}One error, two audiences, each getting what they can act on.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Error boundaries wrap each screen and each non-critical widget.
- Offline is a designed state, never an endless spinner.
- No empty catch blocks; deliberate ignores carry a comment.
- Error reports include feature tags and identifying context.
- User-facing messages say what happened and what to do next.
- Retries use exponential backoff with a maximum attempt count.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/react-error-handling/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: react-error-handling
description: >-
Error boundaries, offline-first states, structured error reporting, and
retry policy for React and React Native. Use when adding error handling,
reviewing failure paths, or designing offline behaviour.
argument-hint: "[feature or screen]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Error Handling
Apply when writing failure paths or reviewing a feature for robustness. On mobile, treat offline as a normal operating condition rather than an exceptional one.
## Rules
1. **Contain failures at a meaningful boundary** — One boundary at the app root turns any component error into a blank screen. Boundaries per screen and around each risky widget mean a failing recommendations carousel costs you a carousel, not a session.
2. **Mobile is offline-first whether you designed for it or not** — Lifts, tunnels, and rural coverage are normal operating conditions. A spinner that never resolves is the most common way this failure shows up, and it is indistinguishable from a hung app.
3. **Show users what to do, log engineers what happened** — A raw stack trace helps nobody in the UI, and 'Something went wrong' helps nobody in your logs. Split the audience: an actionable message on screen, full context in the report.
4. **Never swallow an error silently** — An empty `catch` converts a loud failure into a mysterious one. If an error is genuinely safe to ignore, the comment explaining why is mandatory.
5. **Every retry needs a backoff and a ceiling** — Immediate infinite retries turn a brief server wobble into a self-inflicted denial of service, and drain the battery while doing it.
## Patterns
### Boundary granularity
**Avoid** — Maximum blast radius. A non-essential widget can take down the entire session.
```tsx
<ErrorBoundary fallback={<BlankScreen />}>
<App />
</ErrorBoundary>
// One bad render anywhere = the whole app is a blank screen.
```
**Prefer** — Nested boundaries scale the fallback to what actually broke.
```tsx
<ErrorBoundary fallback={<AppCrashScreen />}>
<Navigation>
<ErrorBoundary fallback={<ScreenError onRetry={reset} />}>
<ProductScreen>
{/* Non-critical widget fails alone. */}
<ErrorBoundary fallback={null}>
<Recommendations />
</ErrorBoundary>
</ProductScreen>
</ErrorBoundary>
</Navigation>
</ErrorBoundary>
```
### Offline as a designed state
**Avoid** — Offline is rendered as an infinite loading state, which users read as 'broken'.
```tsx
const { data, isPending } = useProducts();
if (isPending) return <Spinner />;
// No connection → pending forever. Looks like a frozen app.
return <ProductList data={data} />;
```
**Prefer** — Offline is a first-class state with a way forward, and cached content still renders.
```tsx
const isConnected = useNetInfo().isConnected;
const { data, isPending, error, refetch } = useProducts();
if (!isConnected && !data) {
return <OfflineState onRetry={refetch} />;
}
if (isPending) return <ProductListSkeleton />;
if (error) return <ErrorState error={error} onRetry={refetch} />;
// Cached data with a clear staleness signal beats an empty screen.
return (
<>
{!isConnected && <OfflineBanner />}
<ProductList data={data} />
</>
);
```
### Logging with context
**Avoid** — The log has no identifying information, so the report is unactionable and the toast is unhelpful.
```ts
try {
await submitOrder(draft);
} catch (e) {
console.log("error"); // useless
showToast("Something went wrong");
}
```
**Prefer** — One error, two audiences, each getting what they can act on.
```ts
try {
await submitOrder(draft);
} catch (error) {
// Engineers get everything needed to reproduce it.
reportError(error, {
tags: { feature: "checkout" },
extra: { orderId: draft.id, itemCount: draft.items.length },
});
// Users get a specific, actionable message.
showToast(
isNetworkError(error)
? "You appear to be offline. Your order was saved as a draft."
: "We couldn't place your order. Please try again.",
);
}
```
## Review checklist
- [ ] Error boundaries wrap each screen and each non-critical widget.
- [ ] Offline is a designed state, never an endless spinner.
- [ ] No empty catch blocks; deliberate ignores carry a comment.
- [ ] Error reports include feature tags and identifying context.
- [ ] User-facing messages say what happened and what to do next.
- [ ] Retries use exponential backoff with a maximum attempt count.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim