All topics

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

tsx
<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

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>

Nested boundaries scale the fallback to what actually broke.

Offline as a designed state

Avoid

tsx
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

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} />
  </>
);

Offline is a first-class state with a way forward, and cached content still renders.

Logging with context

Avoid

ts
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

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.",
  );
}

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.

Download
.claude/skills/react-error-handling/SKILL.md

Drop 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