Feedback

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, RotateCcw, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/** Everything a function `fallback` receives. */
export interface ErrorBoundaryFallbackProps {
  /** The value that was thrown — not guaranteed to be an Error instance. */
  error: unknown
  /** React's error info; `componentStack` is the tree path down to the throw. */
  errorInfo: React.ErrorInfo | null
  /** Clear the error and remount the subtree. Always callable, even when retries are exhausted. */
  reset: () => void

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/error-boundary.json

Prompt

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 the key of the fragment wrapping children. Flipping hasError alone would re-render a child that is still holding the state that poisoned it, and it would throw again on the spot.
  • resetKeys is a signal, resetOnPropsChange is a hammer — a resetKeys change means the inputs really changed, so it always recovers and hands the subtree a fresh retry budget. resetOnPropsChange fires on any parent render, so it stays behind the failure-streak guard to avoid an infinite render loop.
  • Consecutive-failure streakfailureCount counts failures in a row and returns to 0 the moment a remount commits cleanly. retriesExhausted tells the fallback "this is not transient" without ever disabling the manual retry, which is the user's only way out of the panel.
  • showBoundary re-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 messageshowDetails defaults to process.env.NODE_ENV !== "production", so internal file paths and component names stay off end-user screens while onError still ships the full componentStack to your logger.

On This Page