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
- What happens today when a component blows up
- What an error boundary is
- The two methods:
getDerivedStateFromErrorandcomponentDidCatch - A complete
ErrorBoundaryfor CicloUrbano - Where to place boundaries: global and granular
- What error boundaries do NOT catch
- Development versus production
react-error-boundary, the practical alternative- Logging the error to a monitoring service
- Designing a good failure message
- 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:
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.
- 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.
- The two methods:
getDerivedStateFromError and componentDidCatch
getDerivedStateFromError and componentDidCatchHere'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 AppIt 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.
- A complete
ErrorBoundary for CicloUrbano
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
hasErrorflag to decide what to paint, and theerroritself, so it can show details or hand it to the fallback. renderreturnsthis.props.childrenuntouched 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.fallbackaccepts 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,childrenmounts 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
bindis the one from 02-02:retrygets passed as a handler and would lose itsthiswithout 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.
- 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.
- 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.
- 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:
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.
react-error-boundary, the practical alternative
react-error-boundary, the practical alternativeWriting 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.
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.
- 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.
- 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 owntry/catchand their own error state. - Expecting it to catch a failed
fetch. It doesn't either: async code is out of scope. Catch it withtry/catchand 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 tocomponentDidCatch. - A fallback that can also fail. If your message reads
error.response.data.messageand 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
pricePerHourcan be missing, writebike.pricePerHour ?? 0in 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.
BikeCardreadsbike.pricePerHour.toFixed(2)andpricePerHourisundefined.handleConfirmcalls afetchthat returns a 500.- A
setTimeoutinside an effect throws an error two seconds later. - A legacy class component's constructor throws when it receives invalid props.
- A boundary's fallback tries to read
error.details.codeanddetailsdoesn'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 components — static 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
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
