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>
);
}
Step-by-step #
- 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. - 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. - Await the reset. If
onResetis async — clearing IndexedDB, refreshing a token — the remount must wait for it, or the children race the cleanup. - Back off between attempts. Zero delay on the first retry (the user just asked), then 1s, 3s, 7s. The
disabledplusaria-busypairing communicates the wait to everyone. - 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.
- 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.
Verification #
- 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.
- Arm a permanent failure. Confirm the cooldown lengthens, the button reports
aria-busy, and the control becomes “Reload the page” after the cap. - 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.
- 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. - 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.
Related #
- Fallback UI Rendering Patterns — the anatomy of the fallback this control lives in
- State Reset & Cleanup Protocols — what must be torn down before a remount is safe
- Resetting Error Boundaries on Route Change in React Router — the other reset trigger every app needs