⚛ The Course  /  Module 7 · Suspense, Errors & Activity · 7.2
Module 7 · Suspense, Errors & Activity

7.2 Error boundaries: what they catch

You wrap part of your app in an Error Boundary so a single crash shows a friendly message instead of a blank white screen. But then a button handler throws, and the boundary does nothing — the error sails straight past it to the browser console. This lesson explains the one simple rule that decides whether a boundary catches an error or not, and why that rule exists.

What an Error Boundary actually is

An Error Boundary is just a class component with one or both of these special methods:

  • static getDerivedStateFromError(error) — returns new state so the next render can show an error UI.
  • componentDidCatch(error, info) — a place to run a side effect, usually logging.

When a component below the boundary throws a non-promise during render, React unwinds up to the nearest Error Boundary and marks its fiber with a flag called DidCapture.

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };   // update state so the next render shows the error UI
  }

  componentDidCatch(error, info) {
    logErrorToService(error, info);   // optional side-effect (logging)
  }

  render() {
    if (this.state.hasError) return <h1>Something went wrong.</h1>;
    return this.props.children;
  }
}

The DidCapture flag forces a re-render

Normally React tries to skip work. If a component's props, context, and pending lanes are all unchanged, React bails out — it reuses last time's result without calling the component again. That is a key optimization.

But a boundary that just caught an error must render again, even though nothing about its props changed — it needs to call getDerivedStateFromError and switch to the error UI. So the DidCapture flag deliberately turns the bailout off.

  no DidCapture  →  props unchanged?  →  yes  →  bail out (skip the component)
  DidCapture set →  ignore that check  →  always re-render → show fallback UI

What that looks like during a render pass

The flag is read at the very top of beginWork, the function that processes one fiber. If the flag is on, React sets didReceiveUpdate = true, which is its internal way of saying "do not bail out."

T=0ms  Render child → throws Error
T=1ms  Mark ErrorBoundary with DidCapture:  workInProgress.flags |= DidCapture
T=2ms  Unwind stack to ErrorBoundary
T=3ms  beginWork(<ErrorBoundary>)
         (flags & DidCapture) !== 0 → didReceiveUpdate = true (no bailout)
         call getDerivedStateFromError → update state: { hasError: true }
T=4ms  Render ErrorBoundary's error UI → <h1>Something went wrong.</h1>

The real code — beginWork in ReactFiberBeginWork.js

function beginWork(current, workInProgress, renderLanes) {
  // ... bailout checks ...

  if ((workInProgress.flags & DidCapture) !== NoFlags) {
    // this fiber caught an error
    // must re-render even if props / context / lanes unchanged
    didReceiveUpdate = true;   // NO bailout
  }
  // ... continue rendering ...
}

Notice this is the exact same flag that Suspense uses in lesson 7.1. A Suspense boundary that catches a thrown promise also gets DidCapture and re-renders its fallback. The trigger differs — a promise versus a real error — but the "stop here, then render the recovery UI" mechanism is shared.

The one rule: was the throw inside React's own try-catch?

Now the important part — which errors reach a boundary at all. There is a single dividing line, and it is not about what threw but where the code was running when it threw.

React wraps its own work in try/catch. So anything React itself calls — your component body, the commit phase, your effects — is protected. Anything the browser calls directly, after React has handed control away, is not.

┌──────────────────────────────────────────────────────────────┐
│              REACT'S CONTROLLED EXECUTION                    │
│         (Error Boundaries CAN catch these)                   │
├──────────────────────────────────────────────────────────────┤
│  1. Render phase (function component body)                   │
│  2. Commit phase                                             │
│  3. useEffect / useLayoutEffect — React runs these!          │
└──────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────┐
│              OUTSIDE REACT'S CONTROL                         │
│         (Error Boundaries CANNOT catch these)                │
├──────────────────────────────────────────────────────────────┤
│  1. Event handlers (onClick, onChange, …)                    │
│  2. setTimeout / setInterval callbacks                       │
│  3. Promise .then() / .catch() callbacks                     │
│  4. async/await after the await                              │
│  5. requestAnimationFrame callbacks                          │
└──────────────────────────────────────────────────────────────┘

Why a sync throw inside useEffect IS caught

An effect feels like "later" code, so it is tempting to assume a boundary can't reach it. But React schedules and runs effect callbacks itself, inside a try/catch. So a throw there is caught and forwarded to the nearest boundary.

The real code — commitPassiveMountEffects

function commitPassiveMountEffects(root, finishedWork) {
  try {
    const create = effect.create;
    effect.destroy = create();   // ← your throw happens here
  } catch (error) {
    captureCommitPhaseError(finishedWork, error);  // propagates to nearest ErrorBoundary
  }
}

Why an event handler throw is NOT caught

React attaches a single listener at the root and dispatches events through it (this is delegation, covered in lesson 5.4). But when it finally calls your handler, it does not wrap that call in a try/catch — so a throw there escapes to window.onerror.

// How React attaches event listeners (simplified)
element.addEventListener('click', (event) => {
  const listener = getListener(fiber, 'onClick');
  listener(event);   // ← if this throws, React can't catch it → window.onerror
});

Why async / Promise .then() throws are NOT caught

useEffect(() => {
  fetch('/bla').then(response => {
    throw new Error('Async error');   // ❌ runs in microtask queue, outside React
  });
}, []);

The useEffect callback returns immediately after calling fetch() — no throw at that point, so React's surrounding try/catch sees nothing. The .then() fires later, in a browser microtask, long after React has finished its execution context.

The full picture in one table

Error locationCaught by Error Boundary?Why
Render phase (component body)✅ YesReact's render is wrapped in try-catch
useEffect sync throw✅ YesReact executes it in commitPassiveMountEffects
useLayoutEffect sync throw✅ YesSame — commit phase wrapper
Event handler throw❌ NoBrowser calls it directly, no React wrapper
Promise .then() throw❌ NoRuns in microtask queue, outside React
setTimeout callback❌ NoBrowser scheduler calls it
async/await after await❌ NoContinuation runs outside React's context

The fix: push uncatchable errors back into render

If an error happens somewhere a boundary can't reach, you can still get it caught: catch it yourself, store it in state, and re-throw it during render — which is inside React's try-catch.

const Component = () => {
  const [error, setError] = useState(null);
  if (error) throw error;   // re-throw during render → ErrorBoundary catches it

  const onClick = () => {
    try { riskyOperation(); }
    catch (e) { setError(e); }   // triggers re-render → throw during render
  };

  useEffect(() => {
    fetch('/bla')
      .then(res => res.json())
      .catch(e => setError(e));  // same pattern for async errors
  }, []);

  return <button onClick={onClick}>Click</button>;
};

A related trap: Suspense without JavaScript

Boundaries and Suspense share the same recovery machinery, and they also share a failure mode on the server. When you render on the server and stream HTML, a suspended Suspense boundary sends its fallback first and the real content later, hidden. A tiny inline <script> swaps them once the data lands. If JavaScript never runs, that swap never happens.

Root cause: display: none !important applied by JS

When a Suspense boundary is suspended during SSR streaming, React (the server renderer, called Fizz) sends the fallback inline and the real content inside a hidden wrapper. The reveal is done by JavaScript stamping and un-stamping an inline style on the host nodes:

The real code — hideInstance / unhideInstance

function hideInstance(instance) {
  var style = instance.style;
  if (typeof style.setProperty === 'function') {
    style.setProperty('display', 'none', 'important');  // inline !important
  } else {
    style.display = 'none';
  }
}

function unhideInstance(instance, props) {
  var styleProp = props[STYLE];
  var display = styleProp != null && styleProp.hasOwnProperty('display')
    ? styleProp.display : null;
  instance.style.display = dangerousStyleValue('display', display);
}

// Called from the commit phase when OffscreenComponent visibility changes:
case OffscreenComponent:
  if (flags & Visibility) {
    var isHidden = finishedWork.memoizedState !== null;
    hideOrUnhideAllChildren(offscreenBoundary, isHidden);
  }

The primary content lives inside an Offscreen fiber, which is what gets the display: none !important. The fallback sits outside that Offscreen, so it is never hidden:

Suspense (suspended)
├─ Offscreen (mode="hidden")   ← PRIMARY CONTENT wrapped here — gets display:none
│  └─ AsyncComponent
└─ Fragment                    ← FALLBACK — no inline style → VISIBLE
   └─ div "Loading…"

With vs without JavaScript

SSR STREAMING — WITH JavaScript
[SERVER]                                    [BROWSER]
  emit shell + fallback ("Loading…") ──────→ paints shell + fallback ✅
  data resolves on server
  stream: <div hidden id="S:0">real</div>
          <script>$RC("B:0","S:0")</script> → $RC runs: removes fallback,
                                               moves real content into place
                                               → real content VISIBLE ✅
  later: hydrateRoot() attaches event handlers

SSR STREAMING — WITHOUT JavaScript
[SERVER]                                    [BROWSER, JS disabled]
  emit shell + fallback ("Loading…") ──────→ paints shell + fallback ✅
  data resolves on server
  stream: <div hidden id="S:0">real</div>
          <script>$RC("B:0","S:0")</script> → $RC NEVER runs
                                             → real content stays inside <div hidden>
                                             → fallback "Loading…" stays forever ❌

The correct patterns

The takeaway: do not put SEO-critical or must-see content behind Suspense if it must survive without JS. Fetch it on the server and render it directly. Keep Suspense for non-critical enhancements.

// ❌ Bad for critical/SEO content — hides if JS fails
<Suspense fallback={<Loading />}>
  <CriticalContent />
</Suspense>

// ✅ Good — fetch on the server, render directly (no Suspense needed)
const data = await fetchCriticalData();
<CriticalContent data={data} />
Show the full progressive-enhancement pattern and the attempted workarounds
// ✅ Progressive enhancement pattern
export default async function Page() {
  const criticalData = await fetchCritical();

  return (
    <>
      {/* Always visible — no Suspense */}
      <CriticalContent data={criticalData} />

      {/* Enhanced with JS only — acceptable for non-critical UI */}
      <Suspense fallback={<Optional />}>
        <Enhancement />
      </Suspense>
    </>
  );
}
/* ❌ FAILS — inline !important has higher specificity than any stylesheet */
[hidden] { display: inherit !important; }
<!-- ⚠️ HACKY — affects other elements; both fallback + content visible at once -->
<noscript>
  <style>[style*="display: none"] { display: block !important; }</style>
</noscript>