This page completes the fallback anatomy described in Fallback UI Rendering Patterns: the part where the user gets their feature back.

The exact problem #

The fallback says “Something went wrong. Try again.” The user clicks. Nothing happens. They click four more times, then reload the page and lose the form they were filling in.

The button is not decorative — it is wired to a reset function. The problem is that resetting the boundary’s error state re-renders the same children, which synchronously read the same cached rejected promise, which throws again before the browser paints. From the outside it is indistinguishable from a dead button.

A retry that works has to do three things in order: clear the failure’s cause, remount the subtree, and give the user feedback about what happened.

Zero-to-working retry #

// RetryableBoundary.tsx
interface Props {
  scope: string;
  children: React.ReactNode;
  /** Clear whatever cached the failure — a query key, a resource, a store slice. */
  onReset?: () => void | Promise<void>;
  maxAttempts?: number;
}

interface State { error: Error | null; attempt: number; cooling: boolean }

export class RetryableBoundary extends React.Component<Props, State> {
  state: State = { error: null, attempt: 0, cooling: false };

  static getDerivedStateFromError(error: Error) {
    return { error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    reportCrash({ scope: this.props.scope, message: error.message, componentStack: info.componentStack ?? undefined });
  }

  retry = async () => {
    const nextAttempt = this.state.attempt + 1;
    // Backoff grows with attempts: 0s, 1s, 3s, 7s… capped.
    const cooldown = nextAttempt === 1 ? 0 : Math.min(2 ** (nextAttempt - 1) - 1, 15) * 1000;

    this.setState({ cooling: cooldown > 0 });
    if (cooldown) await new Promise((r) => setTimeout(r, cooldown));

    // 1. Clear the cause BEFORE remounting, or the children re-read the failure.
    await this.props.onReset?.();

    // 2. attempt++ is also the remount key, so children get fresh state.
    this.setState({ error: null, attempt: nextAttempt, cooling: false });
  };

  render() {
    const { error, attempt, cooling } = this.state;
    const exhausted = attempt >= (this.props.maxAttempts ?? 3);

    if (error) {
      return (
        <ErrorFallback
          error={error}
          attempt={attempt}
          cooling={cooling}
          exhausted={exhausted}
          onRetry={this.retry}
        />
      );
    }
    return <React.Fragment key={attempt}>{this.props.children}</React.Fragment>;
  }
}
// ErrorFallback.tsx — the accessible half
export function ErrorFallback({ error, attempt, cooling, exhausted, onRetry }: FallbackProps) {
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    // Focus the fallback heading so a keyboard user is not left on a
    // button that just vanished from the DOM.
    headingRef.current?.focus();
  }, []);

  return (
    <section role="alert" aria-live="assertive" className="fallback" style={{ minHeight: 'var(--slot-height, 220px)' }}>
      <h3 ref={headingRef} tabIndex={-1}>This section didn’t load</h3>
      <p>
        {attempt === 0
          ? 'A temporary problem stopped it from loading. Your other work is unaffected.'
          : `Attempt ${attempt} didn’t work either.`}
      </p>
      {exhausted ? (
        <button type="button" onClick={() => window.location.reload()}>
          Reload the page
        </button>
      ) : (
        <button type="button" onClick={onRetry} disabled={cooling} aria-busy={cooling}>
          {cooling ? 'Retrying shortly…' : 'Try again'}
        </button>
      )}
    </section>
  );
}
What a working retry does, in order A four-step sequence: clear cached failure, bump remount key, remount children, then a branch. On success the recovered region renders and focus moves to its heading. On failure the fallback returns with attempt plus one and a longer cooldown, and after three attempts the control becomes reload. clear the cause query cache / resource bump reset key attempt + 1 children remount fresh state, fresh effects renders throws again recovered focus moves to the region heading fallback again longer cooldown after 3 attempts the control becomes "Reload the page"

Step-by-step #

  1. Clear the cause first. With TanStack Query that is queryClient.resetQueries({ queryKey }); with a hand-rolled resource cache it is deleting the entry. Skipping this is the single most common reason a retry appears dead.
  2. Change a key, do not just clear state. key={attempt} forces React to unmount and remount the subtree, so component state, refs and effects all start clean. Clearing the error alone re-renders a tree that may still hold broken derived state.
  3. Await the reset. If onReset is async — clearing IndexedDB, refreshing a token — the remount must wait for it, or the children race the cleanup.
  4. Back off between attempts. Zero delay on the first retry (the user just asked), then 1s, 3s, 7s. The disabled plus aria-busy pairing communicates the wait to everyone.
  5. Cap and escalate. After three failures the situation is not transient; offering the reload — and saying what it costs — beats a button that keeps failing.
  6. Move focus deliberately. Focus the fallback heading when it appears, and the recovered region’s heading when it succeeds. Both are tabIndex={-1} so they can receive focus without joining the tab order, following the pattern in Building Accessible Error Fallback UI with ARIA Live Regions.
Failure cause Does a plain retry help? What the control should do
Transient network error Yes Retry immediately, then back off
Server 500 that persists No Retry twice, then explain and offer reload
Deterministic render bug No Retry once so the user is not blocked, then escalate
Stale bundle after a deploy No Skip retry; offer reload directly
Expired session No Escalate to the session boundary, not a retry
Quota exhaustion during a write Sometimes Free space first, then retry once

Keeping the layout still #

A retry that reflows the page is only marginally better than a reload. Reserve the slot’s height in the fallback (min-height: var(--slot-height)) and keep the same height in the recovered state, so the transition does not shift the content below. The measurement approach — and how to derive --slot-height from the real component — is covered in Implementing Fallback Rendering Without Layout Shift.

Focus path across a retry cycle Three connected boxes showing focus movement. Focus starts on the fallback heading when the fallback appears, moves to the try again button when the user tabs, and on success moves to the recovered region heading. A crossed-out branch shows the failure mode where focus falls back to document body. fallback heading focused on appear, tabIndex -1 "Try again" button aria-busy while cooling recovered heading focused after success without explicit focus: document body keyboard user restarts from the top of the page Cooldown between retry attempts Four bars of increasing height representing the wait before each retry: no wait on the first, one second on the second, three on the third and seven on the fourth, after which a marked box shows the control switching to reload the page. attempt 1 · no wait 2 · 1 s 3 · 3 s 4 · 7 s control becomes "Reload the page" the wait is announced with aria-busy so the button never looks broken

Verification #

  1. Arm a single-shot failure. With the harness from Testing & Fault Injection for Error Boundaries, the first render fails and the retry must succeed — proving the remount actually re-executes the component.
  2. Arm a permanent failure. Confirm the cooldown lengthens, the button reports aria-busy, and the control becomes “Reload the page” after the cap.
  3. Watch the network tab. Each retry must issue exactly one new request. Two requests means the cache clear and remount are both refetching; zero means the cache was never cleared.
  4. Tab through the fallback. Focus must land on the heading, then the button, and after a successful retry on the recovered heading — never on document.body.
  5. Measure layout shift. Record a performance trace across a retry; the CLS contribution should be zero if the slot height is reserved.

Frequently asked questions #

Why does my retry button do nothing?

Because the boundary clears its error state while the children re-render against the same cached rejection, throwing again in the same tick. Change a reset key to force a remount and clear the cache entry that produced the failure.

Where should focus go after a successful retry?

To the heading of the recovered region, made focusable with tabIndex={-1}. The retry button is unmounted by recovery, so focus would otherwise fall to document.body.

Should retry be automatic instead of a button?

One automatic attempt for a network-shaped failure is reasonable. Beyond that, make it explicit: automatic retries on a deterministic crash loop, burn battery, and multiply crash reports.