Imagine CicloUrbano's server returns a bike without the pricePerHour field. BikeCard tries to do bike.pricePerHour.toFixed(2), JavaScript throws "Cannot read properties of undefined," and the result isn't a broken card: the entire application disappears. Header, catalogue, booking form, footer: a blank screen. Since React 16, an uncaught error during render unmounts the whole tree, and that deliberate behaviour makes a piece you don't know yet indispensable: the error boundary. In this lesson you'll learn what it is, how it's implemented (with the one React 19 exception that still requires a class component), where to place it, what kinds of errors it does not catch and what to do about those, and how to design a failure message that doesn't scare off whoever reads it.

Contents

  1. What happens today when a component blows up
  2. What an error boundary is
  3. The two methods: getDerivedStateFromError and componentDidCatch
  4. A complete ErrorBoundary for CicloUrbano
  5. Where to place boundaries: global and granular
  6. What error boundaries do NOT catch
  7. Development versus production
  8. react-error-boundary, the practical alternative
  9. Logging the error to a monitoring service
  10. Designing a good failure message

  1. What happens today when a component blows up

Let's trigger it on purpose. Add a broken bike to CicloUrbano's data:

// src/data/domain.js (for the demo only)
export const brokenBike = {
  id: 'bici-999',
  model: 'Prototype',
  type: 'urbana',
  status: 'disponible',
  stationId: 'est-01'
  // pricePerHour is missing!
};

BikeCard receives it and runs this line, which you already wrote in 02-05:

const formattedPrice = bike.pricePerHour.toFixed(2);
// TypeError: Cannot read properties of undefined (reading 'toFixed')

What you see in the browser with the app built for production:

(nothing — a completely empty <div id="root">)

And in the console:

Uncaught TypeError: Cannot read properties of undefined (reading 'toFixed')
The above error occurred in the <BikeCard> component.
Consider adding an error boundary to your tree to customize error handling behavior.

Why does React unmount everything? The decision was made in React 16, and the reasoning goes like this: if a component has failed halfway through render, the tree is left in an inconsistent, unknown state. Leaving the interface half-done is worse than removing it: it could show wrong balances, buttons that trigger the wrong action, or forms that submit corrupt data. In a payments app, a corrupt interface is more dangerous than no interface at all.

But "blank screen" isn't an acceptable answer for the person using the app either. React's solution is to let you decide what to show instead, and that's the job of the error boundary.

  1. What an error boundary is

An error boundary is a component that catches JavaScript errors thrown anywhere in its subtree of children, keeps the whole application from unmounting, and paints an alternative interface in its place.

The four properties that define its behaviour:

  • It catches downward, not sideways. It protects its descendants, never its siblings or itself.
  • It behaves like a JavaScript catch, but for components. The error "bubbles up" through the tree until it finds the first boundary.
  • It replaces the entire subtree. When it catches, everything that hung off it unmounts and the alternative gets painted. There's no partial repair.
  • If there's no boundary anywhere in the chain, the error reaches the root and React unmounts the application: the blank screen from section 1.
flowchart TD
    ROOT["main.jsx / createRoot"] --> LG["GLOBAL ErrorBoundary"]
    LG --> APP["App"]
    APP --> CAB["Header ✅ still alive"]
    APP --> LC["ErrorBoundary «Catalogue»"]
    APP --> LR["ErrorBoundary «Bookings»"]
    LC --> LIS["BikeList"]
    LIS --> T1["BikeCard"]
    LIS --> T2["BikeCard 💥 error"]
    LR --> FR["BookingForm ✅ still alive"]
    T2 -. "the error bubbles up" .-> LC
    style LC fill:#fde68a
    style T2 fill:#fecaca

In this tree, a card's failure gets caught by the catalogue boundary: the bike list is lost and a message is shown instead, but the header, the booking form, and the footer keep working. That's the whole idea.

  1. The two methods: getDerivedStateFromError and componentDidCatch

Here's the exception flagged back in 04-03 and 04-04: there is no hook equivalent. An error boundary has to be a class component, because the two methods that make it possible are only available on classes. React acknowledges this openly in its own documentation, and there's work in progress toward an alternative, but as of today — React 19 — the class is mandatory.

The two methods do different things, and it's worth not confusing them.

static getDerivedStateFromError(error)

static getDerivedStateFromError(error) {
  // Must return an object with the new state, or null to leave it unchanged
  return { hasError: true };
}
Aspect Detail
It's static Belongs to the class, not the instance. Has no this
When it runs During the render phase, right after a descendant throws
What it receives The thrown error object
What it must return A state object (merged with the current one), or null
What it's for Only for deciding that the fallback needs to be painted
What it must NOT do Side effects: no fetch, no console.log, no logging

The ban on side effects isn't a suggestion: this method runs during render, which must be pure (04-03), and React can call it more than once for the same error. A log written here could get sent twice.

componentDidCatch(error, errorInfo)

componentDidCatch(error, errorInfo) {
  // errorInfo.componentStack: the path of components down to the failure
  logToService(error, errorInfo.componentStack);
}
Aspect Detail
It's an instance method Does have this: can read props and state
When it runs After the fallback renders, during the commit phase
What it receives The error and an object with componentStack
What it's for Side effects: log the error, notify a monitoring service
What it shouldn't do Decide what gets painted (that's the previous method's job)

componentStack is the most valuable piece of information you'll get from a production failure:

    in BikeCard (created by BikeList)
    in BikeList (created by AdvancedPanel)
    in AdvancedPanel (created by App)
    in App

It isn't the JavaScript call stack: it's the component path, and it tells you exactly which part of the interface failed.

Summary of the division of labour: getDerivedStateFromError paints; componentDidCatch logs. You can implement only the first one, but then you'll have no way of knowing what's failing in production.

  1. A complete ErrorBoundary for CicloUrbano

// src/components/ErrorBoundary.jsx
import { Component } from 'react';
import styles from './ErrorBoundary.module.css';

/**
 * Error boundary for CicloUrbano. The project's ONLY class: the two methods
 * it needs have no hook equivalent in React 19.
 *
 * Props:
 *  - children (protected content, required)
 *  - fallback (JSX or function (error, retry) => JSX, optional)
 *  - title    (string, optional): heading of the default message
 *  - onLog    (function, optional): receives (error, componentStack)
 */
class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
    this.retry = this.retry.bind(this);
  }

  // 1. PAINT: runs during render. No side effects.
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  // 2. LOG: runs afterwards. This is where side effects go.
  componentDidCatch(error, errorInfo) {
    console.error('[ErrorBoundary] error caught:', error);
    console.error('[ErrorBoundary] components:', errorInfo.componentStack);

    if (this.props.onLog) {
      this.props.onLog(error, errorInfo.componentStack);
    }
  }

  // 3. RETRY: go back to a healthy state and remount the subtree
  retry() {
    this.setState({ hasError: false, error: null });
  }

  render() {
    if (!this.state.hasError) {
      return this.props.children;      // normal case: it doesn't get in the way at all
    }

    const { fallback, title = "We couldn't display this section" } = this.props;

    // The fallback can be a function, to receive the error and the retry
    if (typeof fallback === 'function') {
      return fallback(this.state.error, this.retry);
    }

    if (fallback) {
      return fallback;
    }

    // Default fallback
    return (
      <section className={styles.boundary} role="alert">
        <h2 className={styles.title}>{title}</h2>
        <p className={styles.text}>
          A technical problem has occurred and this part of CicloUrbano couldn't be
          loaded. The rest of the page keeps working normally.
        </p>
        <button type="button" className={styles.button} onClick={this.retry}>
          Try again
        </button>
      </section>
    );
  }
}

export default ErrorBoundary;

Rundown of the design decisions:

  • State holds two things: the hasError flag to decide what to paint, and the error itself, so it can show details or hand it to the fallback.
  • render returns this.props.children untouched as long as there's no error. An error boundary is invisible in the normal case: it adds no markup, no styling, no rendering cost.
  • fallback accepts two shapes. If it's JSX, it's painted as-is; if it's a function, it gets called with (error, retry) so whoever's using it can build a custom message with its own button. It's the render props technique from 04-02, applied where it genuinely pays off.
  • The retry button does setState({ hasError: false }). That triggers a normal render, children mounts fresh again, and if the cause was transient — a malformed network response, say — the interface recovers. If the error is permanent, the boundary catches it again and the message shows once more: there's no infinite loop, because a person triggers the retry.
  • role="alert" comes from 03-06: the message appears without anyone moving focus, and assistive technologies need to announce it.
  • The constructor's bind is the one from 02-02: retry gets passed as a handler and would lose its this without it.

Usage

// src/main.jsx — the global boundary
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import ErrorBoundary from './components/ErrorBoundary.jsx';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <ErrorBoundary title="CicloUrbano isn't available right now">
      <App />
    </ErrorBoundary>
  </StrictMode>
);
// src/App.jsx — granular boundaries
<ErrorBoundary
  title="We couldn't load the catalogue"
  fallback={(error, retry) => (
    <Notice
      tone="error"
      title="Catalogue unavailable"
      actions={<button type="button" onClick={retry}>Retry</button>}
    >
      We couldn't display the bikes. You can still check your bookings
      while we sort it out.
    </Notice>
  )}
>
  <BikeList
    bikes={visibleBikes}
    stations={stations}
    onSelect={handleBikeSelect}
    onBook={handleBooking}
  />
</ErrorBoundary>

Notice that the fallback reuses the generic Notice from 04-02: composition again, without a single line of duplicated styling.

  1. Where to place boundaries: global and granular

The recommended strategy combines two levels.

One global boundary at the root. It's the safety net of last resort: it guarantees a blank screen never appears, no matter what. It wraps <App /> in main.jsx and shows a full-app message, with the option to reload.

Several granular boundaries around the independent sections. The question that decides where to put one is always the same:

What part of the interface can be lost without the application stopping being useful?

In CicloUrbano there are three clear answers:

Section What it protects What keeps working if it fails
Bike catalogue BikeList and its cards Header, bookings, stations
Booking panel BookingPanel and BookingForm The whole catalogue is still browsable
Station map StationCard and its counters Catalogue and bookings
flowchart TD
    M["main.jsx"] --> LG["🛡️ GLOBAL ErrorBoundary<br/>«CicloUrbano is unavailable»"]
    LG --> APP["App"]
    APP --> CAB["Header<br/>(no boundary: it's static markup)"]
    APP --> L1["🛡️ ErrorBoundary «Catalogue»"]
    APP --> L2["🛡️ ErrorBoundary «Bookings»"]
    APP --> L3["🛡️ ErrorBoundary «Stations»"]
    APP --> PIE["Footer"]
    L1 --> CAT["TypeSelector + BikeList + BikeCard"]
    L2 --> RSV["BookingPanel + BookingForm"]
    L3 --> EST["StationCard + DockCounter"]

Practical criteria for deciding:

  • Wrap what consumes external data. Anything that depends on an API is a candidate: malformed data is cause number one of render errors.
  • Wrap what's third-party. A chart, a map, a player: code you don't control.
  • Wrap every route. Once React Router arrives (Module 6), a boundary per route stops a failure on one screen from taking down the whole navigation.
  • Don't wrap every component. A boundary per card looks safer, but it fills the code with noise and lets a failure disguise itself as a "broken card" instead of standing out. The right granularity is the section, not the element.
  • Remember a boundary doesn't protect itself. If your fallback's JSX throws an error, the failure bubbles up to the boundary above it. Keep fallbacks simple: text, a button, and little else, with no calculations or access to fields that might be missing.

  1. What error boundaries do NOT catch

This section is the one that prevents a false sense of security. An error boundary catches only errors thrown during render, in lifecycle methods, and in the constructors of its subtree. Everything else is out of scope.

Error type Caught? What to do instead
A descendant's render Yes It's its whole reason for existing
A descendant's lifecycle Yes
A descendant's constructor Yes
Event handlers (onClick, onSubmit) No try/catch inside the handler
Async code (setTimeout, promises, fetch) No try/catch with async/await, or .catch()
Server rendering No The framework's own error handling (Next.js, 10-01)
The boundary's own failures No Caught by the boundary above it; keep the fallback simple

Event handlers

The reason is simple: a handler runs outside of render, once React isn't building anything anymore. A failure there doesn't leave the tree inconsistent, so React doesn't step in and the error just lands in the console.

// src/App.jsx (excerpt)
function App() {
  const [bookingError, setBookingError] = useState(null);

  async function handleConfirm({ bike, hours, total }) {
    setBookingError(null);
    try {
      await submitBooking({ bicicletaId: bike.id, hours, total });
      setSelectedBike(null);
    } catch (error) {
      console.error('[App] could not create the booking', error);
      setBookingError("We couldn't confirm your booking. Please try again.");
    }
  }

  return (
    <>
      {bookingError && (
        <Notice tone="error" title="Booking not confirmed">{bookingError}</Notice>
      )}
      {/* … */}
    </>
  );
}

The pattern is always the same: try/catch in the handler and its own error state that gets painted with the Notice from 04-02. It's more work than a boundary, but it also allows a much more precise message, because you know exactly which operation failed.

Async code

// BROKEN: the boundary does NOT catch this
useEffect(() => {
  setTimeout(() => {
    throw new Error('deferred failure');   // leaves React's context
  }, 1000);
}, []);
// CORRECT: catch it and turn it into state
useEffect(() => {
  let cancelled = false;

  async function load() {
    try {
      const response = await fetch('/api/bicicletas');
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const data = await response.json();
      if (!cancelled) setBikes(data);
    } catch (error) {
      if (!cancelled) setLoadError(error.message);
    }
  }

  load();
  return () => { cancelled = true; };   // cleanup: lesson 04-03 in action
}, []);

There's a trick to get an async error to a boundary: store it in state and rethrow it during render.

const [fatalError, setFatalError] = useState(null);
if (fatalError) {
  throw fatalError;      // now it happens during render: the boundary catches it
}

Use it sparingly, and only for errors that genuinely prevent the section from being shown; for everything else, an error state and a Notice give a better experience. In Module 7 you'll see that server-state libraries build this in by default.

  1. Development versus production

One detail that confuses a lot of people the first time they try an error boundary: in development, the error is still visible.

Development (npm run dev) Production (npm run build)
The boundary catches the error Yes Yes
The fallback gets painted Yes Yes
Vite's error overlay appears Yes, on top of everything No
The error prints to the console Yes, always Yes
Error message Full, with stack and components May be minified

Vite's red overlay shows up even though the boundary worked perfectly, and that's intentional: during development you want to hear about every error, not have it swallowed by a silent catch. Close it with Escape and you'll see your fallback underneath, already painted.

To really check the behaviour the public will see:

npm run build
npm run preview

That build has no overlay, and you'll see exactly what the person using the app will see. It's a check worth running every time before signing off on a boundary: it's easy to write a fallback that itself fails — reading error.response.data, say — and not notice in development because the overlay hides the result.

  1. react-error-boundary, the practical alternative

Writing the class once is fine; writing it in every project, less so. The react-error-boundary library is the community's de facto standard, and it encapsulates everything from section 4, plus the part that's hardest to get right: the reset.

npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary';

function CatalogueFallback({ error, resetErrorBoundary }) {
  return (
    <Notice
      tone="error"
      title="Catalogue unavailable"
      actions={<button type="button" onClick={resetErrorBoundary}>Retry</button>}
    >
      We couldn't display the bikes ({error.message}).
    </Notice>
  );
}

// Usage
<ErrorBoundary
  FallbackComponent={CatalogueFallback}
  onError={(error, info) => logToService(error, info.componentStack)}
  onReset={() => reloadBikes()}
  resetKeys={[chosenType]}
>
  <BikeList bikes={visibleBikes} stations={stations} />
</ErrorBoundary>

What it adds over the hand-rolled implementation:

Feature What it's for
FallbackComponent A whole component as the fallback, with error and resetErrorBoundary injected
onError The logging hook, without writing componentDidCatch
onReset Runs on retry: the place to reload data or clear state
resetKeys Resets the boundary automatically when any of those values changes
useErrorBoundary A hook to manually send an async error to the nearest boundary

resetKeys solves a real case: if the catalogue fails with the "Cargo" filter and the person switches to "Urban," it makes sense to retry just because of that change, without pressing any button.

Practical recommendation: write the class by hand once to understand the mechanism — which is what you just did — and use react-error-boundary in real projects.

  1. Logging the error to a monitoring service

A boundary that only paints a nice message leaves your team blind: production failures happen in browsers you're not in front of. That's why componentDidCatch matters so much.

// src/utils/monitoring.js

/**
 * Sends an error to the monitoring service.
 * Generic on purpose: swap the URL for your provider's.
 */
export function reportError(error, componentStack, context = {}) {
  if (import.meta.env.DEV) {
    console.error('[monitoring] (development, not sent)', error);
    return;
  }

  const report = {
    message: error.message,
    stack: error.stack,
    components: componentStack,
    url: window.location.href,
    browser: navigator.userAgent,
    timestamp: new Date().toISOString(),
    version: import.meta.env.VITE_VERSION_APP ?? 'unknown',
    ...context
  };

  // `keepalive` lets the send survive a tab closing
  fetch('/api/errores', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(report),
    keepalive: true
  }).catch(() => {
    // If the logging call fails, do nothing else: never trigger a second error
  });
}
// Wired to the boundary
<ErrorBoundary
  title="We couldn't load the catalogue"
  onLog={(error, componentStack) =>
    reportError(error, componentStack, { section: 'catalogue', chosenType })
  }
>
  <BikeList … />
</ErrorBoundary>

Golden rules of error logging:

  • Don't log in development. You'd fill the dashboard with noise from errors you're triggering yourself.
  • Add business context. Knowing the failure was in the "catalogue" section with the "cargo" filter is worth more than a minified call stack.
  • Never send personal data. usr-01's email, a session token, or a form's contents must never go out in an error report. Send identifiers, not data.
  • Logging must never throw. The empty .catch() is intentional: an error inside the error handler is the worst kind of error.
  • Upload the source maps. Without them, the production stack trace is unreadable. Upload them to the service, not to the public server.

In a real project you'll use a commercial service (there are several well-known ones) with its own SDK, but the pattern is identical: a single logging point called from componentDidCatch or from onError.

  1. Designing a good failure message

The boundary works; now the message needs to not ruin the experience. A failure message has four jobs: explain what happened, delimit the scope, offer a way out, and not scare people.

Avoid Prefer
"Error: undefined is not a function" "We couldn't display the catalogue"
"An unexpected error occurred" (and nothing else) Explain which part is failing and what keeps working
Blaming the person ("you did something wrong") Own the problem in the first person plural
A dead end A retry button and an alternative ("check your bookings")
A technical dump on screen A short incident id for support
Alarm emojis and jarring colours A sober tone, with your system's error colour (--color-maintenance)

An example applied to CicloUrbano:

function CatalogueFallback({ error, resetErrorBoundary, incidentId }) {
  return (
    <section className={styles.failure} role="alert">
      <h2>We couldn't display the catalogue</h2>

      <p>
        There's been a problem loading the bikes. The rest of CicloUrbano
        is working normally: you can check your bookings or look up a station.
      </p>

      <div className={styles.actions}>
        <button type="button" onClick={resetErrorBoundary}>Try again</button>
        <a href="/my-bookings">Go to my bookings</a>
      </div>

      {incidentId && (
        <p className={styles.incident}>
          If the problem continues, give this reference to support:{' '}
          <code>{incidentId}</code>
        </p>
      )}
    </section>
  );
}

Four deliberate details:

  • The title says what failed, in the language of the person using the app, not the program's.
  • The second sentence delimits the damage. Knowing that only one section went down completely changes how the problem is perceived.
  • There are two ways out: retry, and go do something else. A message with no way out is a wall.
  • The incident id connects what the person sees with what your team sees on the monitoring dashboard, without showing technical details.

And an accessibility note, closing the thread from 03-06: the container carries role="alert" so it gets announced when it appears, and the message doesn't rely on the colour red to be understood.

Common Mistakes and Tips

  • Expecting the boundary to catch an onClick. It never does. Handlers need their own try/catch and their own error state.
  • Expecting it to catch a failed fetch. It doesn't either: async code is out of scope. Catch it with try/catch and store the error in state, or rethrow it during render if it's genuinely fatal.
  • Putting side effects in getDerivedStateFromError. It runs during render and can get called more than once: logs would come out duplicated. That job belongs to componentDidCatch.
  • A fallback that can also fail. If your message reads error.response.data.message and that field doesn't exist, the boundary throws inside itself and the failure bubbles up to the boundary above it. Keep fallbacks simple and proof against missing data.
  • A single global boundary and nothing else. It succeeds at not leaving a blank screen, but any failure takes down the entire application. Add boundaries per section.
  • A boundary per component. The opposite extreme: noise, tiny messages everywhere, and failures that go unnoticed.
  • Testing only in development. Vite's overlay hides the result. Always check with npm run build && npm run preview.
  • Tip: test your boundaries on purpose. Temporarily add a component that throws (function Bomb() { throw new Error('test'); }) inside each protected section and confirm the rest of the interface survives.
  • Tip: a boundary doesn't replace validation. If you know pricePerHour can be missing, write bike.pricePerHour ?? 0 in the card. The boundary is the safety net for what you hadn't foreseen, not an excuse to skip checking data.

Exercises

Exercise 1. Place CicloUrbano's error boundaries. Write main.jsx with the global boundary and the App.jsx excerpt with three granular boundaries (catalogue, bookings, and stations), each with its own title and its own call to reportError with the section's context. Explain why Header and Footer are left out.

Exercise 2. Classify these five CicloUrbano failures: does an error boundary catch them? If not, say how each one is handled.

  1. BikeCard reads bike.pricePerHour.toFixed(2) and pricePerHour is undefined.
  2. handleConfirm calls a fetch that returns a 500.
  3. A setTimeout inside an effect throws an error two seconds later.
  4. A legacy class component's constructor throws when it receives invalid props.
  5. A boundary's fallback tries to read error.details.code and details doesn't exist.

Exercise 3. Write BookingsFallback, the booking interface for the booking panel's boundary. Requirements: reuse the Notice component from 04-02 with tone error; show a clear title; explain what keeps working; offer a "Try again" button and a link to the catalogue; show a short incident id generated with crypto.randomUUID().slice(0, 8); and be failure-proof (it must not throw even if error is null). Explain where the id must be generated and why it can't be generated in the component body.

Solutions

Solution 1.

// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import ErrorBoundary from './components/ErrorBoundary.jsx';
import { reportError } from './utils/monitoring.js';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <ErrorBoundary
      title="CicloUrbano isn't available right now"
      onLog={(error, stack) => reportError(error, stack, { section: 'global' })}
    >
      <App />
    </ErrorBoundary>
  </StrictMode>
);
// src/App.jsx (excerpt of the return)
<Layout>
  <ErrorBoundary
    title="We couldn't load the catalogue"
    onLog={(e, stack) => reportError(e, stack, { section: 'catalogue', chosenType })}
  >
    <TypeSelector chosenType={chosenType} onTypeChange={handleTypeChange} />
    <BikeList
      bikes={visibleBikes}
      stations={stations}
      onSelect={handleBikeSelect}
      onBook={handleBooking}
    />
  </ErrorBoundary>

  <ErrorBoundary
    title="We couldn't load your bookings"
    onLog={(e, stack) => reportError(e, stack, { section: 'bookings' })}
  >
    <BookingPanel
      bike={selectedBike}
      hours={bookingHours}
      onHoursChange={handleHoursChange}
      onConfirm={handleConfirm}
    />
  </ErrorBoundary>

  <ErrorBoundary
    title="We couldn't load the stations"
    onLog={(e, stack) => reportError(e, stack, { section: 'stations' })}
  >
    <StationList stations={stations} />
  </ErrorBoundary>
</Layout>

Header and Footer are left out for two reasons. First, they're practically static markup: they don't consume external data or run calculations on fields that could be missing, so their odds of failure are minimal. Second, if they failed anyway, losing navigation leaves the app unusable: there, a local fallback isn't what you want — you want the failure to bubble up to the global boundary and show a full-app message. A boundary gets placed where it makes sense to keep using the rest.

Solution 2.

Case Caught? Handling
1. pricePerHour undefined during render Yes It's the canonical case. Even so, the right move is to prevent it with bike.pricePerHour ?? 0 and leave the boundary as a safety net
2. fetch returning 500 in a handler No Double reason: it's a handler and it's async. try/catch with async/await and a bookingError state shown with Notice
3. setTimeout that throws two seconds later No The callback runs outside React's context. try/catch inside the callback and store the error in state
4. A legacy class's constructor Yes Descendants' constructors are covered, just like their lifecycle methods
5. The fallback reads a field that doesn't exist No, that boundary doesn't catch it A boundary doesn't protect itself: the error bubbles up to the boundary above it (the global one). Fix: a defensive fallback, error?.details?.code ?? 'no code'

Solution 3.

// src/components/BookingsFallback.jsx
import Notice from './Notice.jsx';

/**
 * Booking interface for the booking panel's error boundary.
 * Props:
 *  - error      (Error object, optional): can arrive null
 *  - onRetry    (function, required)
 *  - incidentId (string, optional)
 */
function BookingsFallback({ error, onRetry, incidentId }) {
  const detail = error?.message ?? 'Unknown error';

  return (
    <Notice
      tone="error"
      title="We couldn't load your bookings"
      actions={
        <>
          <button type="button" onClick={onRetry}>Try again</button>
          <a href="/catalogue">View the catalogue</a>
        </>
      }
    >
      <p>
        There's been a problem with the booking panel. The bike catalogue and
        the stations keep working normally.
      </p>
      {incidentId && (
        <p>
          Reference for support: <code>{incidentId}</code>
        </p>
      )}
      <p className="technical-detail">{detail}</p>
    </Notice>
  );
}

export default BookingsFallback;

The id can't be generated in the component body for two reasons. The first is about correctness: crypto.randomUUID() in the body would return a different value on every render, so the reference the person sees would change every time the component repaints and would no longer match what got sent to the monitoring service. The second is conceptual: generating a random value during render makes it impure, and render must be pure (04-03).

The right place is the moment the error gets caught, meaning inside componentDidCatch, which runs exactly once per failure and does allow side effects:

componentDidCatch(error, errorInfo) {
  const incidentId = `inc-${crypto.randomUUID().slice(0, 8)}`;
  this.setState({ incidentId });
  if (this.props.onLog) {
    this.props.onLog(error, errorInfo.componentStack, incidentId);
  }
}

That way, the same id travels to the monitoring service and shows up on screen: whoever calls support will give exactly the reference the team can look up. It's the same principle BookingForm follows in 03-05, generating its res-${crypto.randomUUID().slice(0, 8)} ids in the submit handler and not during render.

Conclusion

An error thrown during render unmounts React's entire tree and leaves a blank screen. Error boundaries are the answer: components that catch failures in their subtree and paint an alternative interface. They're implemented with two methods that, as of today, only exist on class componentsstatic getDerivedStateFromError to decide what to paint and componentDidCatch to log — and that's the one exception left to the rule that everything gets written with functions and hooks. You've built ErrorBoundary for CicloUrbano with a customizable fallback and a retry button, placed it on two levels — one global in main.jsx and several granular ones per section — and you know it does not catch event handlers, async code, or its own failures, along with the right technique for each case. Add to that logging to a monitoring service with business context, react-error-boundary as a practical option for everyday work, and failure messages that explain, delimit, and offer a way out.

This closes out Module 4. Across four lessons the project has changed scale: state moved up to the common ancestor and the catalogue genuinely filters (04-01); the interface got recomposed with containers, named slots, and specializations instead of inheritance (04-02); you learned to read the classic lifecycle and translate it (04-03); and you laid out the full map of hooks along with the rules that govern them (04-04).

Now it's time to walk that map in detail. Module 5: React Hooks dedicates one lesson to each tool: useState in full depth with all its nuances, useEffect and the synchronization model announced in 04-03, useRef for what gets remembered without repainting, useContext to put an end to the prop drilling left open in 04-01, useReducer for complex state, and finally custom hooks, where your own logic turns into a reusable function without a single wrapper. The next lesson is The useState Hook.

React Course

Module 1: Getting Started with React

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved