// LOADING
// LOADING
// LOADING_ARTICLE
When a component throws during render, React by default unmounts the entire tree. In development you see a red overlay; in production your users see nothing — a blank white page with no explanation. Error boundaries are React's mechanism for catching these render-time exceptions before they propagate to the root, rendering a fallback in place of the broken subtree.
Understanding when errors are caught, when they are not, and how to design the fallback experience are three distinct skills. This post covers all three.
An error boundary is a class component that implements componentDidCatch and/or getDerivedStateFromError. There is no hook equivalent — class components are required for this particular lifecycle. In practice, you write one and reuse it everywhere.
tsximport { Component, ReactNode } from 'react'; interface Props { fallback: ReactNode; children: ReactNode; } interface State { hasError: boolean; error: Error | null; } export class ErrorBoundary extends Component<Props, State> { state: State = { hasError: false, error: null }; static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } componentDidCatch(error: Error, info: { componentStack: string }) { // Log to your monitoring service here console.error('Boundary caught:', error, info.componentStack); } render() { if (this.state.hasError) { return this.props.fallback; } return this.props.children; } }
getDerivedStateFromError fires during the render phase and lets you update state to show the fallback. componentDidCatch fires during the commit phase and is the right place to log errors to an external service.
The placement strategy is about blast radius. A single boundary wrapping the entire app means one broken widget takes the whole page offline. Fine-grained boundaries let sections fail independently.
A reasonable default is three levels:
tsx// In a Next.js layout export default function Layout({ children }: { children: React.ReactNode }) { return ( <ErrorBoundary fallback={<PageErrorFallback />}> <Navbar /> <main>{children}</main> </ErrorBoundary> ); }
Next.js App Router has first-class support for this via error.tsx files. Dropping an error.tsx in a route segment is the framework's way of declaring a route-level boundary — it receives the error and a reset function to retry rendering.
tsx// app/notelogs/error.tsx 'use client'; export default function Error({ error, reset, }: { error: Error & { digest?: string }; reset: () => void; }) { return ( <div> <h2>Something went wrong loading this notelog.</h2> <button onClick={reset}>Try again</button> </div> ); }
Note the 'use client' directive — error.tsx must be a Client Component because error boundaries rely on class lifecycle methods.
This is the part that trips people up. Error boundaries catch render errors — exceptions thrown synchronously during the render of a component tree. They do not catch:
onClick, onSubmit, etc.)useEffectFor async errors in event handlers, use try/catch and local error state:
tsxfunction SubmitButton() { const [error, setError] = useState<string | null>(null); async function handleSubmit() { try { await submitForm(); } catch (e) { setError('Submission failed. Please try again.'); } } return ( <> <button onClick={handleSubmit}>Submit</button> {error && <p role="alert">{error}</p>} </> ) }
If you are using optimistic UI updates with useOptimistic, the rollback on failure handles the state cleanup, but you still need to surface the error visibly through a toast or inline message.
A fallback that just says "Error" is nearly as bad as a blank screen. Good fallback UI:
reset() to attempt re-rendering the boundary's children.The built-in error.tsx reset function is straightforward. For custom class boundaries, you need to expose a reset path:
tsxexport class ErrorBoundary extends Component<Props, State> { state: State = { hasError: false, error: null }; reset = () => this.setState({ hasError: false, error: null }); static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } render() { if (this.state.hasError) { return ( <div> <p>Something went wrong.</p> <button onClick={this.reset}>Retry</button> </div> ); } return this.props.children; } }
Be careful: resetting re-renders the children, which will throw again if the underlying cause is still present. A retry after a failed data fetch makes sense; a retry after a programming error in the component does not.
Error boundaries and Suspense boundaries are separate concerns but are often placed together. A Suspense boundary handles the loading state; an error boundary handles the failure state. If a data fetch rejects, React will surface the error to the nearest error boundary — not the Suspense boundary — which is why they are usually nested:
tsx<ErrorBoundary fallback={<ErrorState />}> <Suspense fallback={<LoadingSpinner />}> <AsyncComponent /> </Suspense> </ErrorBoundary>
For a deeper look at how Suspense works with streaming, see React Suspense and Streaming Explained. The two features compose cleanly, but understanding each independently first makes the combination more legible.
For patterns around handling async errors in your API layer, Error Handling Patterns in Node.js APIs covers the server side of this story — what your API should return when things fail, and how client-side code can interpret those responses.
Error boundaries are not optional polish. They are the difference between a recoverable incident and a full user-facing outage. Place them thoughtfully — at the root, at each route, and around risky widgets — implement useful fallback UI, log errors to your observability stack, and test the failure paths as deliberately as you test the happy path.