How to Fix React Error Boundary Not Catching Errors in Async useEffect
Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 10 mins
TL;DR
- What broke:
async useEffectthrows a rejected Promise. Error Boundaries only intercept synchronous render/lifecycle errors — they are completely blind to unhandled Promise rejections. - How to fix it: Catch the async error inside the effect, store it in local state, then re-throw it synchronously during render so the boundary can intercept it.
- Fast path: Use our Client-Side Sandbox above to paste your component and auto-refactor this pattern instantly.
The Incident (What Does the Error Mean?)
Your browser console shows something like:
UnhandledPromiseRejectionWarning: TypeError: Cannot read properties of undefined
at fetchData (Dashboard.jsx:14)
at async useEffect (Dashboard.jsx:9)
The above error occurred in the <Dashboard> component.
Consider adding an error boundary to the tree above <Dashboard>.
The boundary never fires. React's Error Boundary lifecycle — componentDidCatch and getDerivedStateFromError — hooks into the synchronous React fiber reconciler. When an async function throws, the exception surfaces as a Promise rejection on the microtask queue, completely outside React's call stack. The boundary is never invoked. In production, this silently kills the component subtree with no fallback UI rendered.
The Attack Vector / Blast Radius
This is not a minor UX bug. The blast radius in a production app:
- Silent white screens. Users see a blank component with zero feedback. No fallback UI. No retry prompt.
- Swallowed data-fetch failures. If
fetchUser()orfetchOrders()rejects (expired token, 503, network drop), the error is invisible to your monitoring boundary. Sentry and your Error Boundary both miss it unless you explicitly forward it. - Memory leaks compound the failure. If the async operation resolves after the component unmounts and you have no cleanup, you get a secondary
setState on unmounted componentwarning stacked on top of the original failure. - Cascading subtree death. Parent boundaries cannot rescue children whose errors never bubble up through the render cycle. The entire subtree is orphaned.
How to Fix It (The Solution)
Basic Fix — Re-throw Inside Render via State
The canonical pattern: catch the async error, store it in state, and re-throw synchronously inside the render path.
- // ❌ BROKEN: Error Boundary will NEVER catch this
- useEffect(() => {
- async function fetchData() {
- const data = await api.getDashboard(); // throws on 401
- setDashboard(data);
- }
- fetchData();
- }, []);
+ // ✅ FIXED: Capture async error, re-throw synchronously in render
+ const [asyncError, setAsyncError] = useState(null);
+
+ useEffect(() => {
+ async function fetchData() {
+ try {
+ const data = await api.getDashboard();
+ setDashboard(data);
+ } catch (err) {
+ setAsyncError(err); // store the error in state
+ }
+ }
+ fetchData();
+ }, []);
+
+ // Re-throw synchronously during render — NOW the Error Boundary catches it
+ if (asyncError) throw asyncError;
Why this works: throw asyncError executes synchronously inside the React render function. The fiber reconciler catches it, walks up the tree, finds your ErrorBoundary, and calls getDerivedStateFromError. The boundary renders your fallback UI as intended.
Enterprise Best Practice — Reusable useAsyncError Hook
Don't scatter useState(null) + if (err) throw err across 40 components. Extract it.
- // ❌ Ad-hoc, duplicated in every component
- const [asyncError, setAsyncError] = useState(null);
- if (asyncError) throw asyncError;
+ // ✅ Reusable hook — drop into any component
+ // hooks/useAsyncError.ts
+ import { useState, useCallback } from 'react';
+
+ export function useAsyncError() {
+ const [, setError] = useState();
+ return useCallback(
+ (err: Error) => {
+ setError(() => {
+ throw err; // throw inside setState updater = synchronous render throw
+ });
+ },
+ [setError]
+ );
+ }
+
+ // Usage in component:
+ const throwError = useAsyncError();
+
+ useEffect(() => {
+ async function fetchData() {
+ try {
+ const data = await api.getDashboard();
+ setDashboard(data);
+ } catch (err) {
+ throwError(err); // delegates to boundary cleanly
+ }
+ }
+ fetchData();
+ }, [throwError]);
The setState updater trick: Throwing inside a setState updater function forces React to treat it as a render-phase error. This is the most reliable, lint-safe pattern — no if (err) throw err littered in JSX return paths.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
Stop this class of bug from merging in the first place.
1. ESLint Rule — @typescript-eslint/no-floating-promises
+ // .eslintrc.js
+ rules: {
+ '@typescript-eslint/no-floating-promises': 'error',
+ '@typescript-eslint/no-misused-promises': 'error',
+ }
This flags any async function call whose returned Promise is not awaited or .catch()-ed. Catches the root pattern at lint time, before review.
2. Require Error Boundary Coverage in Component Tests
+ // In your Jest/RTL test for any data-fetching component:
+ it('renders error boundary fallback on fetch failure', async () => {
+ api.getDashboard.mockRejectedValue(new Error('Network Error'));
+ render(
+ <ErrorBoundary fallback={<div>Error occurred</div>}>
+ <Dashboard />
+ </ErrorBoundary>
+ );
+ expect(await screen.findByText('Error occurred')).toBeInTheDocument();
+ });
Make this a required test pattern in your PR template checklist for any component that fetches data.
3. Global Unhandled Rejection Monitoring (Defense in Depth)
+ // index.tsx — catch what slips through anyway
+ window.addEventListener('unhandledrejection', (event) => {
+ Sentry.captureException(event.reason);
+ event.preventDefault();
+ });
This is your last line of defense — not a substitute for fixing the boundary pattern, but ensures no silent failure escapes your observability stack.