This page is part of the Custom Hooks for Async Error Catching guide, which covers the broader problem of async failures escaping React’s synchronous render cycle.
The exact failure this page solves #
A component fires a fetch inside a click handler and never attaches a .catch() — maybe because the author assumed an upstream try/catch covered it, or a dependency’s promise chain silently drops a rejection two .then() calls deep. The promise rejects. Nothing in the component tree is listening. The browser’s own dispatch mechanism takes over: it fires a PromiseRejectionEvent named unhandledrejection on the window object and logs the reason to the console with a generic, componentless stack trace.
This is a different failure mode from the one covered in Managing State Reset After Uncaught Promise Rejections: that page assumes the rejection has already reached component state. Here, the rejection never touches React at all — it goes straight to the browser’s global event target, stripped of which component fired it, which prop triggered it, or what the user was doing. No error boundary in the tree observes it because boundaries only intercept synchronous throws during render and lifecycle execution, and unhandledrejection fires on a task queue turn that React’s reconciler has no visibility into.
The fix is a single global listener, installed once near the root of the application, that intercepts every unhandled rejection and every uncaught synchronous error that escapes to window, normalizes the payload, filters out noise that isn’t actionable, and — for anything judged critical — throws it back into a React render so the nearest boundary can render a fallback. Everything else is quietly logged and dropped.
Zero-to-working implementation #
The component below is a complete, drop-in provider. Mount it once near the root of the tree, above your top-level error boundary.
// GlobalRejectionProvider.tsx
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
interface CaughtRejection {
message: string;
stack?: string;
}
const RejectionContext = createContext<CaughtRejection | null>(null);
// Known noisy patterns that aren't actionable app errors
const NOISE_PATTERNS: RegExp[] = [
/ResizeObserver loop/i,
/^Script error\.?$/,
/chrome-extension:\/\//,
/moz-extension:\/\//,
];
function normalize(reason: unknown): CaughtRejection {
if (reason instanceof Error) return { message: reason.message, stack: reason.stack };
if (typeof reason === 'string') return { message: reason };
try {
return { message: JSON.stringify(reason) };
} catch {
return { message: String(reason) };
}
}
function isNoise(message: string): boolean {
return NOISE_PATTERNS.some((pattern) => pattern.test(message));
}
export function GlobalRejectionProvider({ children }: { children: ReactNode }) {
const [caught, setCaught] = useState<CaughtRejection | null>(null);
const seen = useRef<Set<string>>(new Set());
useEffect(() => {
if (typeof window === 'undefined') return; // SSR / Node guard
const handleRejection = (event: PromiseRejectionEvent) => {
event.preventDefault(); // stop the default console spam
const { message, stack } = normalize(event.reason);
if (isNoise(message) || seen.current.has(message)) return;
seen.current.add(message);
setCaught({ message, stack });
};
const handleError = (event: ErrorEvent) => {
// Some engines fire `error` alongside `unhandledrejection` for the same
// failure — dedupe on message so the boundary only sees it once.
if (isNoise(event.message) || seen.current.has(event.message)) return;
seen.current.add(event.message);
setCaught({ message: event.message, stack: event.error?.stack });
};
window.addEventListener('unhandledrejection', handleRejection);
window.addEventListener('error', handleError);
return () => {
window.removeEventListener('unhandledrejection', handleRejection);
window.removeEventListener('error', handleError);
};
}, []);
if (caught) throw new Error(caught.message); // handed to the nearest boundary
return <RejectionContext.Provider value={caught}>{children}</RejectionContext.Provider>;
}
export function useGlobalRejection(): CaughtRejection | null {
return useContext(RejectionContext);
}
Step-by-step explanation #
-
SSR guard. The very first line of the effect checks
typeof window === 'undefined'and bails. This matters because the effect body still runs during server rendering in some frameworks’ test harnesses, andPromiseRejectionEventdoes not exist in Node. -
Register both listeners in one effect.
unhandledrejectionanderrorare attached together because some browsers (notably older WebKit builds) fire anerrorevent for a rejection that also firesunhandledrejection, and you want a single place that owns both subscriptions and their teardown. -
event.preventDefault()stops console spam. Without it, every caught rejection still prints the browser’s default red console entry even though your handler already processed it — doubling the noise for exactly the failures you’re trying to make quieter. -
normalize()produces one consistent shape. Rejections can be thrown with anError, a plain string, or an arbitrary object (some GraphQL clients reject with a response body). Normalizing to{ message, stack }means everything downstream — logging, the boundary, telemetry — deals with one shape. -
isNoise()and theseenSet dedupe and filter. A single failing dependency can fire the same rejection dozens of times per second (a polling loop, a WebSocket reconnect storm). TheSetkeyed on message text ensures the boundary renders once, not once per occurrence, and the regex denylist drops known non-actionable browser and extension noise before it ever reaches state. -
Escalation and cleanup. Throwing inside the function body during render is what hands the error to React’s nearest
componentDidCatchboundary — React’s own reconciler treats a throw during render identically whether it originated from JSX or from thisifstatement. Thereturnfunction of the effect removes both listeners, which matters on Fast Refresh and on any remount of the provider.
Edge cases #
| Scenario | Symptom | Mitigation |
|---|---|---|
Double-fire (error + unhandledrejection) |
Boundary renders twice for one failure | Dedupe both handlers against the same seen Set, keyed on the normalized message |
| SSR / server components | ReferenceError: window is not defined at module evaluation |
Guard with typeof window !== 'undefined' inside the effect, never at module scope |
| Third-party / extension noise | Boundary fires for ResizeObserver loop limit exceeded or extension-injected errors |
Match event.reason/event.message against a regex denylist before calling setCaught |
| Listener never removed | Duplicate registrations accumulate across Fast Refresh or remounts, each firing independently | Return the paired removeEventListener calls from the useEffect cleanup, never skip it |
Rejection with a non-serializable reason (e.g. a Response object) |
JSON.stringify throws inside normalize() |
The try/catch around JSON.stringify falls back to String(reason) |
| Errors from a Web Worker | unhandledrejection never fires — workers reject on their own global scope |
Add a matching listener inside the worker and forward via postMessage to the main thread’s provider |
Verification steps #
- Dispatch a synthetic rejection in DevTools. With the provider mounted, run the following in the Console and confirm the fallback UI renders instead of a raw console error:
window.dispatchEvent(
new PromiseRejectionEvent('unhandledrejection', {
promise: Promise.reject(new Error('synthetic test rejection')),
reason: new Error('synthetic test rejection'),
}),
);
-
Confirm console suppression. Watch the Console panel while dispatching the event above — you should see no default “Uncaught (in promise)” entry, since
preventDefault()already ran; any logging you still see should come from your own explicitconsole.errorcall, not the browser’s default. -
Confirm dedupe. Dispatch the same synthetic event three times in a row. The fallback boundary should render exactly once; check
seen.current.sizevia a React DevTools breakpoint to confirm it stayed at 1. -
Confirm cleanup. In the Elements panel, use the Event Listeners tab on the
windowobject before and after unmounting the provider (e.g. by navigating away) — theunhandledrejectionanderrorentries should disappear, confirming no leaked subscriptions. -
Confirm the SSR guard. Run the component through your server-rendering path (Next.js
next build && next start, or a plainrenderToStringcall) and verify noReferenceErroris thrown before hydration.
Frequently asked questions #
Why doesn’t my existing React error boundary catch these rejections automatically?
componentDidCatch only observes errors thrown synchronously during render, lifecycle methods, or constructors. A promise rejection with no attached .catch() fires as an unhandledrejection event on window, entirely outside React’s call stack, so no boundary in the tree is invoked unless something explicitly re-throws it during a render pass — which is exactly what this provider does.
Does calling event.preventDefault() swallow the error permanently?
No. It only suppresses the browser’s default action of logging the rejection to the console as unhandled. Your handler still receives event.reason in full and remains responsible for logging, telemetry, and deciding whether to escalate the failure into React state via the throw.
How do I avoid catching rejections from browser extensions or third-party scripts?
Filter by message pattern before updating state. Extension-injected errors commonly carry a chrome-extension:// or moz-extension:// origin in the stack, and known noisy browser-internal messages like ResizeObserver loop limit exceeded should be matched against a denylist of regular expressions and discarded before they ever reach setCaught.
Related #
- Custom Hooks for Async Error Catching — parent guide covering component-level async interception patterns this global listener complements
- React Error Boundary Implementation — the boundary component that ultimately renders the fallback UI this provider escalates into
- Error Propagation Strategies — how errors travel up the component tree once a boundary receives them