When a component throws while React renders it and nothing catches the error, React removes your whole app. Learn error boundaries: catch, show a message, try again.
Sometimes a component breaks. It has a bug, or the data it gets is wrong. Then it throws an error while React renders it. This part is about what React does then, and how you can control it.
Earlier parts pointed here. In Part 29, an action that throws showed the nearest error boundary. In Part 30, so did the startTransition from useTransition. And in Part 31, use() waits for a promise. When the promise is rejected, the error goes to an error boundary too.
Here we build an error boundary and put it in the right places. Then we give the user a way to try again. We also look at the errors a boundary can’t catch, and what to do about them.
Try this first
Read this code, but don’t press Run yet.
import { useState } from 'react'
function Price({ amount }: { amount: number }) {
if (amount < 0) {
throw new Error('A price cannot be below zero')
}
return <p>Price: ${amount}</p>
}
export default function App() {
const [amount, setAmount] = useState(5)
return (
<div>
<h1>My shop</h1>
<Price amount={amount} />
<button onClick={() => setAmount(amount - 10)}>Take 10 off</button>
</div>
)
}
throw new Error('...') makes an error and stops the function right there. The rest of Price doesn’t run. Here we throw on purpose, so you can see it happen. In a real app, the error is usually a bug, like reading a field of undefined.
Press Run. The page shows “My shop”, “Price: $5” and a button. Now make a guess. The button takes 10 off, so the price becomes -5, and Price throws. What happens to the heading and the button?
Click Take 10 off.
Everything is gone. The heading and the button left too, not only the price. The playground shows the error’s message under the result: “A price cannot be below zero”.
Without a boundary, React removes the whole app
We checked this with React 19.3. Before the click, the box that holds the app had one <div> in it. After the click, the box was empty.
React’s docs say it plainly: “By default, if your application throws an error during rendering, React will remove its UI from the screen”. UI means user interface. It is everything the user sees and clicks.
Why remove everything? React’s blog explained the choice when it first made it, in React 16: “it is worse to leave corrupted UI in place than to completely remove it.” Its example fits our shop: “it is worse for a payments app to display a wrong amount than to render nothing.”
React doesn’t give up at once. We counted the calls to Price after the click: two, with Strict Mode on and with it off. React tried the render one more time before it gave up.
Where the error goes
An error that no boundary catches is called uncaught. That means no code caught it. You make a root with createRoot, as in Part 10. There, you can pass three functions for React to call when something goes wrong:
import { createRoot } from 'react-dom/client'
const root = createRoot(document.getElementById('root')!, {
onUncaughtError(error, errorInfo) {
console.log('No boundary caught it:', error, errorInfo.componentStack)
},
onCaughtError(error, errorInfo) {
console.log('A boundary caught it:', error, errorInfo.componentStack)
},
onRecoverableError(error) {
console.log('React fixed this one itself:', error)
},
})
onUncaughtErrorruns when an error is “thrown and not caught by an Error Boundary”, as React’s docs put it.onCaughtErrorruns when a boundary catches one.onRecoverableErrorruns when React gets past an error by itself. You will meet these mostly in apps that the server renders first.
A Vite project passes none of them, so React uses its own. For an uncaught error, React’s own function reports it with reportError. That is the browser’s way to report an uncaught error (Part 30). In development, React also prints this warning:
An error occurred in the <Price> component.
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.
The playground passes its own onUncaughtError. That is what puts the message under the result. In our test, it was called once, and nothing went to the Console. Notice what that means. Once you pass onUncaughtError, React no longer calls reportError for those errors. We tested a listener for the window’s error event. It got the error with React’s own function. It got nothing with ours. So if you pass it, log the error yourself.
Now let’s take the warning’s advice.
Your first error boundary
An error boundary is a component that catches errors from the components inside it. Then it shows something else in their place, called a fallback. A fallback is what you show when the normal thing fails.
Here is the same shop. The only new part is ErrorBoundary, wrapped around <Price />.
import { Component, useState, type ErrorInfo, type ReactNode } from 'react'
type Props = { children: ReactNode }
type State = { error: Error | null }
class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error: error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.log('Caught:', error.message)
console.log(info.componentStack)
}
render() {
if (this.state.error) {
return <p>Something went wrong: {this.state.error.message}</p>
}
return this.props.children
}
}
function Price({ amount }: { amount: number }) {
if (amount < 0) {
throw new Error('A price cannot be below zero')
}
return <p>Price: ${amount}</p>
}
export default function App() {
const [amount, setAmount] = useState(5)
return (
<div>
<h1>My shop</h1>
<ErrorBoundary>
<Price amount={amount} />
</ErrorBoundary>
<button onClick={() => setAmount(amount - 10)}>Take 10 off</button>
</div>
)
}
Run it and click Take 10 off. This time the heading and the button stay. Only the price is replaced, by “Something went wrong: A price cannot be below zero”.
A class, in short
ErrorBoundary doesn’t look like our other components. It is a class. This is the only place in this series where we write one.
A class is a plan for making objects. React makes one object from it for each <ErrorBoundary> on the page. React’s docs put Component in a list of old parts of React. They say: “We recommend defining components as functions instead of classes”. But they also say there is “currently no way to write an Error Boundary as a function component”. Error boundaries are the one job where React still needs a class.
Here is what each part means:
class ErrorBoundary extends Component<Props, State>makes a class built on React’sComponent.PropsandStateare the types of its props and its state.state: State = { error: null }is the starting state. In a function component, that would beuseState(null).thismeans “this one boundary”. Sothis.stateis its state, andthis.props.childrenis whatever you put between its tags.render()returns JSX, like thereturnof a function component. It shows the fallback when there is an error. Otherwise it shows its children.
A function that belongs to a class is called a method. render is one. Two other methods are special. React calls them only when a child throws.
static getDerivedStateFromError(error)runs first.staticmeans it belongs to the class, not to one object, so it can’t usethis. It gets the error and returns the new state. Here that state holds the error, so the next render shows the fallback. React’s docs say it “should be a pure function”. Pure means it only gives back the new state: no logging, no requests. React may call it more than once. We counted: after one click, it ran 4 times with Strict Mode on, and 2 times with it off.componentDidCatch(error, info)runs after that. This is the place for side effects, like logging or sending the error to a server. Our code logs it.
You don’t have to write this class yourself. React’s docs name a package, react-error-boundary, that “does that for you”. The playground can’t import it, so we write our own.
What the Console shows
React prints the caught error with console.error. The playground shows it like this:
A price cannot be below zero
The above error occurred in the <Price> component.
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
A browser’s own console also shows the list of functions the error went through. The next lines name the component that threw, and the boundary that caught it. “Recreate this component tree from scratch” means React builds that part again, now with the fallback.
After that come our two logs, from componentDidCatch. The second one is info.componentStack. It lists the components, from the one that threw up to the top. In our test, its four lines started with at Price, at ErrorBoundary, at div and at App. After each name comes the place where that code lives. That part looks different in each browser.
The component stack tells you where the error happened, which helps when you read errors later. In a production build, React’s docs warn, “the component names will be minified”. Minified means made shorter, so Price might become one letter.
Where to put boundaries
A boundary shows its fallback in place of everything inside it. So where you put it decides what keeps working.
Here are two small parts, a weather box and a counter. Each has its own boundary. Click Clicks twice, then click Sunny. Break it.
import { Component, useState, type ReactNode } from 'react'
type Props = { name: string; children: ReactNode }
type State = { broken: boolean }
class ErrorBoundary extends Component<Props, State> {
state: State = { broken: false }
static getDerivedStateFromError(): State {
return { broken: true }
}
render() {
if (this.state.broken) {
return <p>The {this.props.name} is not working right now.</p>
}
return this.props.children
}
}
function Weather() {
const [broken, setBroken] = useState(false)
if (broken) {
throw new Error('No weather data')
}
return <button onClick={() => setBroken(true)}>Sunny. Break it</button>
}
function WeatherCard() {
return (
<section>
<h2>Weather</h2>
<Weather />
</section>
)
}
function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>Clicks: {count}</button>
}
export default function App() {
return (
<div>
<ErrorBoundary name="weather box">
<WeatherCard />
</ErrorBoundary>
<ErrorBoundary name="counter">
<Counter />
</ErrorBoundary>
</div>
)
}
The weather box is replaced by a message: “The weather box is not working right now.” The counter still says 2. It still counts, too. Here is the path the error took.
An error goes up to the nearest boundary. Press play, or step through it with the arrows.
- There are two error boundaries. Each one wraps one part of the app.
Weatherthrows an error while React renders it.- The error goes up the tree.
WeatherCardis not a boundary, so the error goes past it. - The nearest boundary above catches it. It shows its fallback in place of everything inside it, the heading too.
- The counter is inside a different boundary. Nothing happened to it, so it still works.
We checked the details. After the weather broke, the Clicks button on the page was the same button as before. Counter didn’t even render again. And when we put one more boundary around the whole app, the nearest one still caught the error. The outer one did nothing.
Now the other two choices. With one boundary around both parts, breaking the weather replaced both. The page showed only the fallback, and the counter was gone. With no boundary at all, the page was empty.
So a good plan has two levels:
- One boundary around the whole app. Then an error never leaves an empty page. The user at least sees a message.
- One boundary around each part that can fail on its own. A side panel, a list of comments, a small box with the weather. Then one broken part doesn’t break the others.
Don’t wrap every small thing. React’s docs give a chat app as an example. A boundary around the list of chats makes sense, and so does one around each message. But “it wouldn’t make sense to place a boundary around every avatar”. An avatar is a person’s small picture. Ask yourself where an error message would make sense to the user.
An everyday example
Many homes have a box of switches. One main switch is for the whole house. Smaller switches are each for one room. If a lamp in the kitchen has a problem, the kitchen’s switch turns off. The kitchen goes dark, but the other rooms keep their lights. With no kitchen switch, the main switch turns off, and the whole house goes dark. Someone has to turn the switch back on later.
Each error boundary is a room switch. The parts inside it are the room. With no boundary, React acts like the main switch: the whole app goes.
The exact version
The picture breaks in two places. First, a boundary doesn’t leave the room dark: it shows a fallback, which can be any JSX. Second, a switch reacts to every problem in its room. A boundary catches only some errors, as we’ll see soon.
Giving the user a way back
Once a boundary shows its fallback, it stays that way. Its state says “broken”, and nothing changes that by itself. You need a way to reset it. Reset means putting it back to the start.
A Try again button
The boundary can reset itself. A button in the fallback sets its state back to no error. Then React renders the children again.
import { Component, useState, type ReactNode } from 'react'
type Props = { children: ReactNode }
type State = { error: Error | null }
class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error: error }
}
render() {
if (this.state.error) {
return (
<div>
<p>Something went wrong: {this.state.error.message}</p>
<button onClick={() => this.setState({ error: null })}>Try again</button>
</div>
)
}
return this.props.children
}
}
function Score({ serverIsUp }: { serverIsUp: boolean }) {
if (!serverIsUp) {
throw new Error('The score server is down')
}
return <p>Score: 42</p>
}
export default function App() {
const [serverIsUp, setServerIsUp] = useState(false)
return (
<div>
<button onClick={() => setServerIsUp(true)}>Fix the server</button>
<ErrorBoundary>
<Score serverIsUp={serverIsUp} />
</ErrorBoundary>
</div>
)
}
this.setState is how a class changes its state. It works like a set function from useState.
Score pretends to load a score. It fails until you click Fix the server. Run it and follow these steps:
- It starts broken: “Something went wrong: The score server is down”.
- Click Try again. The fallback comes back. The server is still down, so
Scorethrew again. In our test, React printed the error again too. - Click Fix the server. Nothing changes.
Apprendered again, but the boundary still holds its error. - Click Try again. Now you see “Score: 42”.
Step 3 is worth noticing. Fixing the cause is not enough. Someone has to reset the boundary.
A key
Part 8 showed that a new key makes React throw a component away and build a new one. That works on a boundary too. It is a good choice when the user moves to another page.
import { Component, useState, type ReactNode } from 'react'
type Props = { children: ReactNode }
type State = { broken: boolean }
class ErrorBoundary extends Component<Props, State> {
state: State = { broken: false }
static getDerivedStateFromError(): State {
return { broken: true }
}
render() {
if (this.state.broken) {
return <p>This page could not be shown.</p>
}
return this.props.children
}
}
const pages: Record<string, string[]> = {
Fruits: ['Apple', 'Mango'],
Toys: ['Ball', 'Kite'],
}
function Page({ name }: { name: string }) {
return <p>{name}: {pages[name].join(', ')}</p>
}
export default function App() {
const [page, setPage] = useState('Fruits')
return (
<div>
<button onClick={() => setPage('Fruits')}>Fruits</button>
<button onClick={() => setPage('Toys')}>Toys</button>
<button onClick={() => setPage('Books')}>Books</button>
<ErrorBoundary key={page}>
<Page name={page} />
</ErrorBoundary>
</div>
)
}
There is no “Books” list, so pages[name] is undefined, and .join throws. In our test, the message was Cannot read properties of undefined (reading 'join'). Browsers word it in different ways.
Click Books, then Toys. The Toys page shows. When page changed, the key changed, so React made a brand new boundary with a fresh state. You saw keys do this in Part 4 and Part 7 too.
What a boundary doesn’t catch
React’s docs list four kinds of errors that boundaries do not catch:
- errors in event handlers, like
onClick; - errors during server rendering;
- errors thrown in the boundary itself, not in its children;
- errors in code that runs later, like a
setTimeoutcallback. The docs namesetTimeoutandrequestAnimationFrame. They also name one exception: thestartTransitionfromuseTransition. Errors thrown in its function do reach the boundary.
Try the first and the last of these. All three break buttons are inside a boundary.
import { Component, useState, type ReactNode } from 'react'
type Props = { children: ReactNode }
type State = { broken: boolean }
class ErrorBoundary extends Component<Props, State> {
state: State = { broken: false }
static getDerivedStateFromError(): State {
return { broken: true }
}
render() {
if (this.state.broken) {
return <p>The boundary caught it.</p>
}
return this.props.children
}
}
function Buttons() {
const [count, setCount] = useState(0)
function breakInClick() {
throw new Error('Broken in a click')
}
function breakInTimer() {
setTimeout(() => {
throw new Error('Broken in a timer')
}, 100)
}
async function breakAfterAwait() {
await new Promise(resolve => setTimeout(resolve, 100))
throw new Error('Broken after await')
}
return (
<div>
<button onClick={breakInClick}>Break in a click</button>
<button onClick={breakInTimer}>Break in a timer</button>
<button onClick={breakAfterAwait}>Break after await</button>
<button onClick={() => setCount(count + 1)}>Clicks: {count}</button>
</div>
)
}
export default function App() {
return (
<ErrorBoundary>
<Buttons />
</ErrorBoundary>
)
}
Run it and press each break button. Then press Clicks. You never see “The boundary caught it.” The playground shows the newest error under the result, for example Uncaught Error: Broken in a click. But the buttons stay, and Clicks keeps counting.
We checked all three with React 19.3. The page stayed the same, and the boundary was never used. The click and timer errors were reported to the browser as uncaught errors. The error after await came from a promise that failed. No code was waiting to deal with that failure.
Why does the boundary miss them? A boundary catches errors while React renders or runs your Effects. A click handler runs later, when the user clicks. A timer runs later still. React isn’t rendering then, so there is no fallback to show. The page that’s on the screen is still fine.
Effects (Part 11) are different from handlers. React runs them itself, after it updates the page. We tried an Effect that throws, and the boundary did catch it.
To at least record the errors a boundary misses, you can listen on the window. window.addEventListener('error', ...) hears uncaught errors, and 'unhandledrejection' hears promises that failed with no one to catch them. The playground uses these two listeners to show the message under the result.
Catch it yourself
For errors in handlers, use try and catch (Part 13), and put the message in state:
import { useState } from 'react'
// A pretend server. It always says no.
function saveName(name: string): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('The server is down')), 500)
})
}
export default function App() {
const [error, setError] = useState<string | null>(null)
async function handleSave() {
setError(null)
try {
await saveName('Ana')
} catch (e) {
setError(String(e))
}
}
return (
<div>
<button onClick={handleSave}>Save</button>
{error && <p>Could not save. {error}</p>}
</div>
)
}
saveName pretends to be a server. After half a second, it says no. Click Save and wait. The message appears under the button, and the button still works. This is usually the best choice: the user sees what failed, near the thing they clicked.
Or send it to the boundary
Sometimes you do want the boundary’s fallback, because the part can’t work without the data. Then put the error in state, and throw it while rendering:
import { Component, useState, type ReactNode } from 'react'
type Props = { children: ReactNode }
type State = { error: Error | null }
class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error: error }
}
render() {
if (this.state.error) {
return <p>The boundary caught it: {this.state.error.message}</p>
}
return this.props.children
}
}
// A pretend server. It always says no.
function saveName(name: string): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('The server is down')), 500)
})
}
function SaveButton() {
const [error, setError] = useState<Error | null>(null)
if (error) {
throw error
}
async function handleSave() {
try {
await saveName('Ana')
} catch (e) {
setError(e as Error)
}
}
return <button onClick={handleSave}>Save</button>
}
export default function App() {
return (
<ErrorBoundary>
<SaveButton />
</ErrorBoundary>
)
}
e as Error tells TypeScript that the caught value is an Error. The handler catches the error and saves it. That makes SaveButton render again. This time it throws during rendering, and the boundary catches it. In our test, the page showed “The boundary caught it: The server is down”.
There is a second way. Run the async work inside the startTransition from useTransition (Part 30). If its function throws, even after await, the nearest boundary shows its fallback. We checked that too.
The boundary’s own errors, and the server
A boundary can’t catch its own errors. We made one whose fallback throws too. The error went up to the next boundary above it, which showed its own fallback. With no boundary above, it would be an uncaught error.
On the server, React doesn’t use your boundary’s fallback. We tried renderToString, which turns components into HTML text on a server. With a boundary around a component that threw, renderToString threw the error. With a <Suspense> around the component as well, it didn’t throw. It sent the Suspense fallback, with a note: “Switched to client rendering because the server rendering errored”. The browser then renders that part itself, and if it throws there too, your boundary catches it.
Errors that do reach a boundary: use and Actions
Two newer kinds of errors do reach a boundary, even though they happen after a wait.
A rejected promise read with use
Part 31 showed use(promise) inside <Suspense>. Suspense is a boundary too, but a different kind. A Suspense boundary shows its fallback while a promise waits. An error boundary shows its fallback when something fails. React’s docs add: “If the Promise is rejected, the fallback of the nearest Error Boundary will be displayed.”
import { Component, Suspense, use, useState, type ReactNode } from 'react'
type Props = { children: ReactNode; onReset: () => void }
type State = { error: Error | null }
class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error: error }
}
render() {
if (this.state.error) {
return (
<div>
<p>Could not load the user. {this.state.error.message}</p>
<button
onClick={() => {
this.props.onReset()
this.setState({ error: null })
}}
>
Try again
</button>
</div>
)
}
return this.props.children
}
}
// A pretend server. After 800 ms it answers, or it says no.
function fetchUser(fail: boolean): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (fail) {
reject(new Error('User not found'))
} else {
resolve('Ana')
}
}, 800)
})
}
const firstTry = fetchUser(true)
function User({ userPromise }: { userPromise: Promise<string> }) {
const name = use(userPromise)
return <p>Hello, {name}!</p>
}
export default function App() {
const [userPromise, setUserPromise] = useState(firstTry)
return (
<div>
<h1>My page</h1>
<ErrorBoundary onReset={() => setUserPromise(fetchUser(false))}>
<Suspense fallback={<p>Loading...</p>}>
<User userPromise={userPromise} />
</Suspense>
</ErrorBoundary>
</div>
)
}
Run it. “Loading…” shows first. After 800 ms, the first promise is rejected. The error boundary shows “Could not load the user. User not found”, and “My page” stays. Without the error boundary, we checked, the whole page went empty, heading too.
Now click Try again. “Loading…” comes back, and then “Hello, Ana!”. The button does two things. onReset is a prop: a function from App that makes a new promise. Then setState clears the error.
The new promise matters. A promise that was rejected stays rejected. We removed this.props.onReset() and tried again. use read the same rejected promise, and the fallback came back at once. Part 31’s Load user button worked the same way: a new try needs a new promise.
You can’t use try and catch around use instead. We tried it, and React warned: “use was called from inside a try/catch block. This is not allowed and can lead to unexpected behavior.” The boundary is the way.
An Action that throws
Part 29 said an action can throw for errors you don’t expect. React’s docs say that then React “shows the nearest Error Boundary”. That means the nearest error boundary.
import { Component, useActionState, type ReactNode } from 'react'
type Props = { children: ReactNode }
type State = { error: Error | null }
class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error: error }
}
render() {
if (this.state.error) {
return <p>The form broke: {this.state.error.message}</p>
}
return this.props.children
}
}
// A pretend server. After 500 ms it fails.
function sendMessage(text: string): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('The server is down')), 500)
})
}
function MessageForm() {
const [sent, formAction, isPending] = useActionState(
async (count: number, formData: FormData) => {
await sendMessage(String(formData.get('message')))
return count + 1
},
0,
)
return (
<form action={formAction}>
<input name="message" aria-label="Message" defaultValue="Hi" />
<button type="submit">{isPending ? 'Sending...' : 'Send'}</button>
<p>Sent: {sent}</p>
</form>
)
}
export default function App() {
return (
<ErrorBoundary>
<MessageForm />
</ErrorBoundary>
)
}
Click Send. The button says “Sending…” for half a second. Then the error boundary shows “The form broke: The server is down”.
We also checked the other shapes from Parts 29 and 30. A plain <form action={fn}> whose function threw reached the boundary. So did the startTransition from useTransition. The startTransition you import from react did not: as Part 30 said, React reports that error as uncaught.
All of these are still errors you don’t expect. For errors you expect, like “Please write a name”, return them as state, as Part 29 showed.
Development and production
The error text you see depends on the build. Development is while you build your app. Production is the finished app your users get. We ran the shop in both builds of React 19.3, with no root options of our own.
| What happened | Development | Production |
|---|---|---|
| No boundary caught it | The error, reported as uncaught, plus React’s “An error occurred in the <Price> component” warning | Only the error, reported as uncaught |
| A boundary caught it | console.error with the error, plus “The above error occurred in the <Price> component” and the “React will try to recreate” line |
console.error with only the error |
In both builds, the shop looked the same, and componentDidCatch ran once. In development it also ran once with Strict Mode on.
React’s docs say one more thing about development. Errors caught by a boundary also “bubble up to window”. So a window error listener would see them. In React 19.3, our listener heard nothing for a caught error, in development too.
The console belongs to each user’s browser, not to you. You don’t see what your users’ consoles print. That is why componentDidCatch, or the root’s onCaughtError, should send errors somewhere you can read them.
Common mistakes
Expecting a boundary to catch a click error
function SaveButton({ save }: { save: () => Promise<void> }) {
// A boundary around this button never sees an error from save().
return <button onClick={() => save()}>Save</button>
}
An error in onClick, in a timer, or after await goes past every boundary. The page stays, but nothing tells the user. Catch it in the handler, and show the message from state:
import { useState } from 'react'
function SaveButton({ save }: { save: () => Promise<void> }) {
const [error, setError] = useState<string | null>(null)
async function handleClick() {
try {
await save()
} catch (e) {
setError(String(e))
}
}
return (
<div>
<button onClick={handleClick}>Save</button>
{error && <p>Could not save. {error}</p>}
</div>
)
}
Or put the error in state and throw it during rendering, as in “Or send it to the boundary”.
One boundary for the whole app, and nothing else
import { Component, type ReactNode } from 'react'
class ErrorBoundary extends Component<{ children: ReactNode }, { broken: boolean }> {
state = { broken: false }
static getDerivedStateFromError() {
return { broken: true }
}
render() {
return this.state.broken ? <p>Something went wrong.</p> : this.props.children
}
}
function WeatherCard() {
return <p>Sunny</p>
}
function Counter() {
return <p>Clicks: 0</p>
}
// The only boundary in the app.
const page = (
<ErrorBoundary>
<WeatherCard />
<Counter />
</ErrorBoundary>
)
It’s better than nothing: the user sees a message instead of an empty page. But then one broken part hides the whole app. In our test, when the weather box broke, the counter was gone too. Add a boundary around each part that can fail on its own, as in “Where to put boundaries”.
No way back
import { Component, type ReactNode } from 'react'
class ErrorBoundary extends Component<{ children: ReactNode }, { broken: boolean }> {
state = { broken: false }
static getDerivedStateFromError() {
return { broken: true }
}
render() {
// No button, and no key from the parent: this message stays.
return this.state.broken ? <p>Something went wrong.</p> : this.props.children
}
}
A fallback with no button and no key stays until the user loads the whole page again. Give the fallback a Try again button, or a key that changes when the user moves on.
Leaving out getDerivedStateFromError
import { Component, useState, type ErrorInfo, type ReactNode } from 'react'
type Props = { children: ReactNode }
class ErrorBoundary extends Component<Props> {
componentDidCatch(error: Error, info: ErrorInfo) {
console.log('Caught:', error.message)
}
render() {
return this.props.children
}
}
function Bomb() {
const [boom, setBoom] = useState(false)
if (boom) {
throw new Error('Boom')
}
return <button onClick={() => setBoom(true)}>Press me</button>
}
export default function App() {
return (
<div>
<p>Outside the boundary</p>
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
</div>
)
}
Run it and press the button. The button goes away, and nothing takes its place. componentDidCatch ran, but nothing told the boundary what to show. The Console still shows React’s usual red entry for a caught error. It even says “React will try to recreate this component tree”. Yet nothing is shown. After it, React printed this:
ErrorBoundary: Error boundaries should implement getDerivedStateFromError(). In that method, return a state update to display an error message or fallback UI.
The fix: add static getDerivedStateFromError() and return a state that shows a fallback.
Catching an error and telling no one
A boundary that shows “Sorry” and does nothing else hides the error. React still prints it to the console. But that console is on the user’s computer, and you don’t see it. The same goes for catch (e) {} with nothing inside the braces.
Send the error somewhere instead. Here reportToService is pretend. A real one would send the error and the component stack to a server.
import { Component, useState, type ErrorInfo, type ReactNode } from 'react'
type Props = { children: ReactNode }
type State = { broken: boolean }
// A pretend error service. A real one would send the error to a server.
function reportToService(error: Error, componentStack: string) {
console.log('Sent to the error service:', error.message)
}
class ErrorBoundary extends Component<Props, State> {
state: State = { broken: false }
static getDerivedStateFromError(): State {
return { broken: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
reportToService(error, info.componentStack ?? '')
}
render() {
if (this.state.broken) {
return <p>Sorry, this part broke.</p>
}
return this.props.children
}
}
function Bomb() {
const [boom, setBoom] = useState(false)
if (boom) {
throw new Error('Boom')
}
return <button onClick={() => setBoom(true)}>Press me</button>
}
export default function App() {
return (
<ErrorBoundary>
<Bomb />
</ErrorBoundary>
)
}
Sent to the error service: Boom
info.componentStack ?? '' means “the stack, or empty text if there is none”. TypeScript says the stack might be missing, so we give it a default.
Practice
Press Edit on the examples above and try these.
- In the weather and counter example, put both parts inside one
<ErrorBoundary name="app">. Click Clicks twice, then break the weather. What shows? - In the “telling no one” example, add
console.log('Bomb renders, boom =', boom)as the first line ofBomb. How manyBomb renderslines does the Console show before the click? How many after it? - In the pages example, remove
key={page}. Click Books, then Toys, then Fruits. What shows? - In the first boundary example, move
<ErrorBoundary>so it wraps the whole<div>. Click Take 10 off. What stays on the page?
Answers
- Only “The app is not working right now.” The counter is gone, because it was inside the same boundary.
- Before the click: two, both
Bomb renders, boom = false, because Strict Mode renders twice. After the click: two more, bothBomb renders, boom = true. The second “true” line is not Strict Mode. We saw it with Strict Mode off too: React tries once more before it shows the fallback. After them comes React’s red entry for the caught error. It starts with “Boom”. Then comesSent to the error service: Boom. - “This page could not be shown.” every time. Without the key, it’s the same boundary, and nothing puts it back to the start.
- Only “Something went wrong: A price cannot be below zero”. The heading and the button were inside the boundary, so the fallback replaced them too.
Interview questions
Try to answer each one out loud before you open the answer.
What happens in React 19 when a component throws while rendering and nothing catches it?
React removes the whole app from the page. React’s blog gave the reason when React 16 made this choice. A broken page can show the user something wrong. A wrong amount in a payments app is worse than showing nothing. React reports the error as uncaught. In development it also prints a warning that suggests an error boundary.
A strong answer names the root options from React 19: onUncaughtError, onCaughtError and onRecoverableError on createRoot. They let you send every error that happens while React renders or runs Effects to your own logging. For the rest, like errors in handlers and timers, a strong answer adds window listeners for error and unhandledrejection.
What is an error boundary, and why must it be a class?
A component that catches errors thrown while its children render, and shows a fallback instead of them. It must be a class because the two methods it needs, static getDerivedStateFromError and componentDidCatch, exist only on class components. React’s docs say there is “currently no way to write an Error Boundary as a function component”. Most apps write one boundary class and use it everywhere, or use the react-error-boundary package.
A boundary has two special methods. What does each one do?
getDerivedStateFromError is static and should be pure. It gets the error and returns new state, so the next render shows the fallback. componentDidCatch runs after, with the error and an info object that has the componentStack. It’s for side effects, like logging. A strong answer adds: with only componentDidCatch, the boundary shows nothing, and React prints a warning about it.
Which errors does an error boundary not catch, and what do you do about them?
Errors in event handlers, and in code that runs later, like setTimeout or code after await. Also errors in the boundary itself, and during server rendering. For handlers, use try and catch and put a message in state. If the part really can’t work, save the error in state. Then throw it during the next render, and the boundary catches it. A strong answer explains why: a boundary works while React renders or runs your Effects, and a handler runs later.
Where should you place error boundaries?
One around the whole app, so an error never leaves an empty page. Then one around each part that can fail on its own, like a side panel. Then the rest keeps working. Not around every small component. A boundary replaces everything inside it, so place it where the fallback message makes sense to the user.
How do you let the user recover after a boundary shows its fallback?
Reset the boundary. A button in the fallback can call this.setState to clear the error. Or the boundary can get a key that changes, for example with the page. A new key makes React build a fresh boundary. A strong answer adds that the cause must be gone too. Otherwise the child throws again and the fallback comes back. For use(), that means a new promise: a rejected promise stays rejected.
Do errors from use() and from Actions reach an error boundary?
Yes. A promise read with use() that is rejected shows the nearest error boundary’s fallback. To retry, give use a new promise. So does an Action that throws, from useActionState, a form’s action, or the startTransition from useTransition. The startTransition imported from react is different: React reports its error as uncaught. You can’t wrap use() in try and catch; React warns about it.
Sources
- Component, react.dev:
getDerivedStateFromError,componentDidCatch,componentStack, minified names in production (and that source maps can turn them back into names), where to place boundaries, the four kinds of errors boundaries don’t catch, andreact-error-boundary. - createRoot, react.dev:
onUncaughtError,onCaughtErrorandonRecoverableError. - React v19, React blog: the new root options, and one console message per caught error.
- Error Handling in React 16, React blog, 2017: why React removes the whole page when nothing catches an error.
- use, react.dev: a rejected promise shows the nearest error boundary, and
usecan’t go insidetryandcatch. - useActionState, useTransition and startTransition, react.dev: which Action errors reach a boundary.
- The console messages, the call counts, the empty page, the production output and every “we checked” above come from running React 19.3.0 for this post.