skip to content

A React error boundary is showing its fallback and the user clicks "Try again". What has to happen for the subtree to genuinely recover, and why does merely clearing the boundary's error state often fail?

level: seniorimportance: should knowfreq 46%

answer

  1. clearing the error alone changes nothing
  2. the cause lives outside the boundary
  3. same props render, same throw
  4. key change forces a fresh mount
  5. cap retries or you loop

basics

~20 s

Recovery needs two steps: clear the boundary's error state so children render again, and clear whatever caused the failure. Clearing only the error re-renders the same inputs, so the component throws immediately and the fallback returns — often in a tight loop.

solid answer

~50 s

Clearing `this.state.error` re-renders the same children with the same props, the same parent state, and the same cached rejected promise — so the throw repeats and the fallback comes straight back, sometimes looping fast enough to pin the CPU. A working retry resets the *cause* as well: refetch or invalidate the cached entry, clear the parent state that produced the unrenderable data, or bump a value the subtree depends on. Remounting is usually the cleanest lever: put a changing `key` on the boundary or the subtree so React discards the old instances and rebuilds from initial state, since the unmounted children have lost their state anyway. `react-error-boundary` encodes exactly this with `resetKeys` (auto-reset when a listed value changes) and `onReset` (your hook for clearing the cause). And because some failures are permanent, cap retries or back off, then offer a full reload rather than retrying forever.

code

jsx · 20 lines
jsx
import { useState } from 'react';
import { ErrorBoundary } from './ErrorBoundary';

export function ReportPanel({ userId, cache }) {
  const [attempt, setAttempt] = useState(0);

  function retry() {
    cache.delete(userId); // reset the cause
    setAttempt(n => n + 1); // remount the boundary
  }

  return (
    <ErrorBoundary
      key={attempt}
      fallback={<button onClick={retry}>Try again</button>}
    >
      <Report userId={userId} cache={cache} />
    </ErrorBoundary>
  );
}

go deeper

for a junior

Know that a fallback does not fix itself: something must clear the boundary's error state before children render again, and a plain "Try again" button often just shows the fallback right back.

for a middle

Explain why: the same props and state produce the same throw. Describe resetting the cause alongside the error, and using a changing key to remount the subtree from scratch.

for a senior

Demonstrate operational judgment — invalidate the cached rejected promise or bad data, keep the attempt counter above the boundary, cap retries with backoff, and separate transient causes from deterministic bugs that should not be retried at all.

for a principal

Set the recovery policy: which failures are retryable, what the attempt budget is, when the app degrades to a reload, and how retry telemetry distinguishes a flaky dependency from a defect that retries are quietly masking.

## Why the naive reset does nothing A boundary's fallback typically renders a button that sets `this.setState({ error: null })`. That re-renders the boundary, which renders `this.props.children` again. But `children` is the same element tree, built by the same parent with the same props and the same state. If the reason the component threw was `user.profile.name` on a `profile` that is `null`, that `null` is still `null`. The child throws on the first render, the boundary catches, and the fallback returns — visually, the button does nothing, and if anything triggers the retry automatically you get an error loop that re-renders as fast as React can schedule it. The useful framing: **the boundary's error state is a symptom, the cause lives outside the boundary.** ## The two things a real retry resets 1. **The boundary state** — so children are rendered at all. 2. **The cause** — whatever made rendering impossible. Common causes and their resets: - a failed request whose result is cached → invalidate or refetch that cache entry; - a rejected promise held by a module-level cache and read with `use()` → replace the cached promise, since re-reading the same rejected promise throws the same rejection forever; - parent state holding malformed data → reset it to a known-good initial value; - a bad URL parameter or filter → navigate or clear the input. If you cannot identify a cause you are able to reset, the honest fallback is "reload the page", not a retry button that pretends. ## Remounting with a key The children were unmounted when the boundary caught, so their state is gone regardless. That makes a full remount the natural recovery, and `key` is how you ask React for one: ```jsx function Panel({ userId }) { const [attempt, setAttempt] = useState(0); return ( <ErrorBoundary key={attempt} fallback={<button onClick={() => setAttempt(n => n + 1)}>Try again</button>} > <Report userId={userId} /> </ErrorBoundary> ); } ``` Changing `key` makes React unmount the old boundary instance — error state and all — and mount a fresh one, which renders `<Report>` from scratch. Note where the `attempt` state lives: in the *parent*, not in the boundary, because the boundary is the thing being thrown away. The same idea applies without a retry button: keying the boundary on the route or on the entity id means navigating elsewhere automatically clears a stuck fallback, which is often the behaviour users expect after a failure. ## What react-error-boundary formalises `<ErrorBoundary resetKeys={[userId]} onReset={() => queryCache.remove(userId)}>` expresses both halves: `resetKeys` clears the error whenever a listed value changes, and `onReset` is the callback where you clear the cause. The fallback receives `resetErrorBoundary` so a button can trigger the same path manually. It is worth being able to describe the semantics even if the codebase hand-rolls its boundary — the design is the point, not the package. ## Guarding against retry loops Automatic retries need a limit. A boundary that resets on every render of its parent, or a `resetKeys` array containing a freshly created object, will retry continuously. Practical guards: - count attempts in the parent and stop offering retry after two or three; - back off between automatic retries rather than retrying immediately; - distinguish transient causes (a 503, a network blip) from permanent ones (a serialization bug, a missing field) and only retry the first class; - after the cap, degrade to a page reload or a "contact support" path. ## What resetting costs the user Remounting is destructive by design: scroll position, expanded rows, in-progress input inside the boundary all disappear. That is another argument for putting boundaries around regions where that loss is acceptable, and for keeping forms and editors *outside* the boundary that guards a volatile widget. ## What the interviewer is testing They want to hear that you know the fallback is not self-healing. The strong answer is: clear the error, clear the cause, remount via `key` because the children's state is gone anyway, cap the retries, and be honest with the user when the failure is permanent.

  • Where should the state that drives a key-based reset live?
    In a component *above* the boundary. Changing the key unmounts and replaces the boundary instance, so any counter stored inside it would be destroyed by the very reset it is meant to survive. Keep the attempt counter in the parent and pass the changing key down.
  • A subtree reads a module-cached promise with use() and it rejected. Why does retrying keep failing?
    Because the cache still holds the same rejected promise, and reading a settled rejected promise throws the same error every time. Recovery has to replace the cache entry with a fresh promise; only then does re-rendering do real work. This is the clearest case of resetting the cause, not the symptom.
  • Should a boundary retry automatically or wait for the user?
    Automatic retry is reasonable for causes that are plausibly transient — a network blip, a 503 — with a small attempt cap and a delay between tries. For deterministic failures it just burns CPU and hides a bug. A user-triggered retry also gives you a clean signal in telemetry about how often recovery is actually attempted.

saying these in an interview costs you the question

  • Assumes clearing error state re-runs the failed request
  • Puts the retry counter inside the boundary being reset
  • Retries automatically with no cap or backoff
  • Thinks children keep their state across a reset
  • Treats every caught error as transient and retryable

context