Error Boundary
A real React error boundary — keyed remount on reset, automatic recovery when resetKeys change, a consecutive-failure guard, onError reporting with the component stack, and dev-only stack details.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/error-boundary.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ErrorBoundary" component using lucide-react
for icons (no other runtime dependencies).
Contract
- export class ErrorBoundary extends React.Component<ErrorBoundaryProps, State>
(a boundary MUST be a class — hooks cannot implement getDerivedStateFromError).
Also export: ErrorBoundaryProps, ErrorBoundaryFallbackProps, a
useErrorBoundary() hook, a withErrorBoundary(Component, boundaryProps) HOC, and
ErrorBoundary as the default export.
- Props: children; fallback?: ReactNode | ((state: ErrorBoundaryFallbackProps) =>
ReactNode); onError?: (error: unknown, info: React.ErrorInfo) => void;
onReset?: () => void; resetKeys?: unknown[]; resetOnPropsChange?: boolean
(default false); isolate?: boolean (default false); maxRetries?: number
(default 3); showDetails?: boolean (default process.env.NODE_ENV !==
"production"); className?: string (merged into the default panel only).
- ErrorBoundaryFallbackProps = { error: unknown; errorInfo: React.ErrorInfo | null;
reset: () => void; failureCount: number; retriesExhausted: boolean }. Type the
thrown value as unknown, never as Error — JS lets you throw anything, and a
string/plain object thrown by a third-party SDK must not crash the fallback.
- Internal state: hasError, error, errorInfo, resetCount, failureCount,
prevResetKeys, prevChildren.
Behavior
- getDerivedStateFromError(error) stores { hasError: true, error } only. The
component stack is not available there, so componentDidCatch(error, info) is
what stores errorInfo, increments failureCount, and calls onError(error, info)
— that is the reporting hook (Sentry, console, your logger). In development
also console.error the caught error plus info.componentStack: React logs the
error itself but the boundary + component stack is what locates the bug.
- Reset must genuinely REMOUNT the subtree, not just flip a flag: render
<React.Fragment key={resetCount}>{children}</React.Fragment> and bump
resetCount on every reset. Without the key, a child still holding the poisoned
state would re-render straight back into the same throw. Nothing else wraps the
children — the happy path adds zero markup to the consumer's layout.
- Automatic recovery is compared during render in getDerivedStateFromProps (no
effect, no extra commit), with two deliberately different policies:
· resetKeys changed (per-index Object.is, or a length change) => ALWAYS clear
the error and reset failureCount to 0. A resetKeys change is an explicit
"the inputs are different now" signal (new route, record, query key); if the
retry guard could veto it, a boundary that already failed maxRetries times
would stay broken forever after the user navigated away from the thing that
broke it.
· resetOnPropsChange + a new `children` identity => clear only while
failureCount < maxRetries. This mode is blunt (children get a fresh identity
on every parent render), so it needs the streak guard: a parent that
re-renders from onReset plus a subtree that always throws is an infinite
render loop. The guard latches on purpose — once exhausted this mode stays
off until a manual reset() or a resetKeys change, so a custom fallback used
with it should always render a retry affordance (retriesExhausted tells it
when to).
Track prevResetKeys AND prevChildren on every change, even when the flag is
off, so toggling resetOnPropsChange later cannot fire a reset for an identity
that changed while it was off.
- failureCount counts CONSECUTIVE failures. componentDidUpdate detects a reset
(resetCount changed), calls onReset, and zeroes failureCount when the commit
landed without an error. A failed reset commits with hasError still true, so
the streak keeps climbing there. retriesExhausted = failureCount >= maxRetries
is passed to the fallback as information — the manual reset() is NEVER blocked,
because it is the only escape hatch inside the panel.
- maxRetries is clamped to an integer >= 1 and falls back to 3 for undefined /
NaN / Infinity: an unclamped NaN makes every `failureCount >= maxRetries`
comparison false and prints "Attempt 2 of NaN".
- Extracting a message from `unknown` never throws: Error => message || name;
string => itself when non-blank; object => a string `.message` field, else
JSON.stringify inside try/catch (ignore "{}"); anything else => String(value)
in try/catch; null/undefined => no message, use a generic sentence.
- useErrorBoundary() returns { showBoundary }. It stores the value in state and
re-throws it during the caller's render, AFTER every hook has run so hook order
never changes — that is the only way an async failure can reach a boundary.
- What a boundary CANNOT catch, and must be documented as such: errors thrown in
event handlers, in setTimeout/requestAnimationFrame callbacks, in unhandled
promise rejections, during server rendering, and errors thrown by the fallback
itself. Route the first three in via showBoundary(); nest another boundary
above for the last one; pair with React 19's createRoot(el, { onCaughtError,
onUncaughtError }) for app-wide reporting of both halves.
Rendering & styling
- Semantic tokens only, no hardcoded colors: panel is border + bg-card; isolate
switches it to border-dashed border-destructive/40 + bg-destructive/5 and sets
a data-isolate attribute; the icon chip is bg-destructive/10 + text-destructive;
the retry button is bg-primary / text-primary-foreground; the exhausted-streak
note is text-destructive; everything secondary is text-muted-foreground. cn()
merges the consumer's className into the default panel's root.
- role="alert" wraps ONLY the heading + message, not the whole panel: an
assertive live region containing the retry button and a full stack trace makes
screen readers read the trace out loud.
- The stack disclosure is a real <button type="button"> with aria-expanded and
aria-controls pointing at the panel it toggles (the panel stays mounted and
uses the `hidden` attribute), the chevron rotates via a class that is disabled
under motion-reduce, and the body is a max-h-48 overflow-y-auto box with
whitespace-pre-wrap + break-words so an arbitrarily long trace scrolls inside
its own box instead of stretching the layout.
- showDetails defaults to development-only: end users get the message, developers
get error.stack + errorInfo.componentStack. Both stacks render in font-mono
text-xs; never colour text with var(--chart-*), which is chroma-0 in a
monochrome palette.
- The whole panel is static — the only transitions are the button's hover colour
and the chevron rotation, both gated by motion-reduce:transition-none.
Customization levers
- fallback: pass a node for a fixed replacement, or a function to own the entire
rendering while still getting reset / failureCount / retriesExhausted. A
one-line inline strip and a full-page crash screen are the same prop.
- Recovery policy: resetKeys for precise, input-driven recovery (recommended);
resetOnPropsChange for "just retry whenever the parent re-renders"; maxRetries
to widen or tighten the streak guard; onReset to invalidate a query cache or
refetch before the subtree comes back.
- isolate marks a widget-level degradation (dashed destructive frame); drop it
for a route-level crash screen, or restyle the whole panel through className
(padding, max-width, text alignment) without touching the tree.
- Detail exposure: showDetails={true} to keep the trace in production behind a
staff-only flag, showDetails={false} to hide it everywhere and ship the stack
to your logger through onError instead.
- Granularity: withErrorBoundary(Widget, { isolate: true }) wraps a component you
do not own the call sites of, so every instance gets its own boundary — one
boundary per tile degrades one cell, one boundary per route degrades the page.Concepts
- Render-phase capture only — a boundary sees throws from render, constructors and lifecycle methods of its subtree. Event handlers, timers, unhandled rejections and server rendering are structurally invisible to it; the demo's last case triggers all three escapes on purpose so the blind spot is something you can watch, not just read about.
- Keyed remount, not a flag flip — reset bumps
resetCount, which is thekeyof the fragment wrappingchildren. FlippinghasErroralone would re-render a child that is still holding the state that poisoned it, and it would throw again on the spot. resetKeysis a signal,resetOnPropsChangeis a hammer — aresetKeyschange means the inputs really changed, so it always recovers and hands the subtree a fresh retry budget.resetOnPropsChangefires on any parent render, so it stays behind the failure-streak guard to avoid an infinite render loop.- Consecutive-failure streak —
failureCountcounts failures in a row and returns to 0 the moment a remount commits cleanly.retriesExhaustedtells the fallback "this is not transient" without ever disabling the manual retry, which is the user's only way out of the panel. showBoundaryre-throws during render — the hook stores the value and throws it after all hooks have run, which keeps hook order stable and converts an async failure into the one shape a boundary can actually catch.- Dev shows the stack, production shows the message —
showDetailsdefaults toprocess.env.NODE_ENV !== "production", so internal file paths and component names stay off end-user screens whileonErrorstill ships the fullcomponentStackto your logger.