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 #

  1. 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, and PromiseRejectionEvent does not exist in Node.

  2. Register both listeners in one effect. unhandledrejection and error are attached together because some browsers (notably older WebKit builds) fire an error event for a rejection that also fires unhandledrejection, and you want a single place that owns both subscriptions and their teardown.

  3. 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.

  4. normalize() produces one consistent shape. Rejections can be thrown with an Error, 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.

  5. isNoise() and the seen Set dedupe and filter. A single failing dependency can fire the same rejection dozens of times per second (a polling loop, a WebSocket reconnect storm). The Set keyed 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.

  6. Escalation and cleanup. Throwing inside the function body during render is what hands the error to React’s nearest componentDidCatch boundary — React’s own reconciler treats a throw during render identically whether it originated from JSX or from this if statement. The return function 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 #

  1. 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'),
  }),
);
  1. 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 explicit console.error call, not the browser’s default.

  2. Confirm dedupe. Dispatch the same synthetic event three times in a row. The fallback boundary should render exactly once; check seen.current.size via a React DevTools breakpoint to confirm it stayed at 1.

  3. Confirm cleanup. In the Elements panel, use the Event Listeners tab on the window object before and after unmounting the provider (e.g. by navigating away) — the unhandledrejection and error entries should disappear, confirming no leaked subscriptions.

  4. Confirm the SSR guard. Run the component through your server-rendering path (Next.js next build && next start, or a plain renderToString call) and verify no ReferenceError is 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.