Two things go wrong in every real app: something breaks, and something is slow. React has built-in answers to both that work for whole sections of the page at once: error boundaries contain breakage, and Suspense handles waiting. This lesson shows both, and how they lead to lazy loading, which makes the first page load smaller.
What happens when a component throws
If a component throws an error while rendering (reading a property of undefined, a bad prop), React cannot show a half-correct page. By default, it removes the entire component tree, and the user sees a blank page. The cause is often one small widget in a corner:
function Profile({ user }) {
return <p>Name: {user.name.toUpperCase()}</p>; // crashes if user.name is missing
}
function App() {
return (
<div>
<h1>My app</h1>
<Profile user={{ name: "Asha" }} />
<Profile user={{}} />
</div>
);
}
export default App;CONSOLE
warn: An error occurred in the <Profile> 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.
Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')Neither the heading nor the first profile appears: the whole page is gone because the second Profile threw. The Console panel shows the error. An error boundary limits such damage to the part of the page that broke.
Error boundaries
An error boundary is a component that catches errors thrown by anything below it in the tree, logs them, and shows a fallback UI instead. React still requires boundaries to be written as class components, using two special methods. You write one once and reuse it:
import { Component, useState } from "react";
class ErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error }; // switch to the fallback on the next render
}
componentDidCatch(error, info) {
console.log("logged:", error.message); // send to a logging service in a real app
}
render() {
if (this.state.error) {
return (
<div role="alert">
<p>Something went wrong in this part of the page.</p>
<button onClick={() => this.setState({ error: null })}>Try again</button>
</div>
);
}
return this.props.children;
}
}
function Profile({ user }) {
return <p>Name: {user.name.toUpperCase()}</p>;
}
function App() {
return (
<div>
<h1>My app</h1>
<Profile user={{ name: "Asha" }} />
<ErrorBoundary>
<Profile user={{}} />
</ErrorBoundary>
<p>The rest of the page still works.</p>
</div>
);
}
export default App;My app
Name: ASHA
Something went wrong in this part of the page.
Try again
The rest of the page still works.
CONSOLE
error: TypeError: Cannot read properties of undefined (reading 'toUpperCase')
The above error occurred in the <Profile> component.
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
logged: Cannot read properties of undefined (reading 'toUpperCase')
error: TypeError: Cannot read properties of undefined (reading 'toUpperCase')
The above error occurred in the <Profile> component.
React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary.
logged: Cannot read properties of undefined (reading 'toUpperCase')Now the heading, the first profile and the closing line all remain; only the broken area shows the message. (The Console also contains React's own error report, because in development React logs every error it catches.) The two methods:
getDerivedStateFromError(error)runs while rendering, and returns the state that makes the boundary show the fallback.componentDidCatch(error, info)runs afterwards, for side effects such as reporting the error to a service (Sentry and similar tools).info.componentStackshows which components were involved.
Place boundaries deliberately: one around the whole app (so the user never sees a blank screen), and extra ones around independent sections such as a sidebar, a chart or each route, so one failure does not take down the rest. A "Try again" button resets the boundary's state, which re-renders the children; it helps if the error was temporary or the data changes.
What boundaries do not catch
They catch errors from rendering, lifecycle methods and constructors of the components below them. They do not catch:
- errors in event handlers (use
try/catchand show an error state), - errors in asynchronous code such as
setTimeoutand promise callbacks (catch them and put them in state), - errors in effects' callbacks (the same),
- errors in server rendering,
- errors thrown in the boundary itself.
If you have a failing fetch, catch it and set an error status, as in the data-fetching lesson. A trick for surfacing an async error in a boundary: set it in state and throw during the next render (if (error) throw error;).
The react-error-boundary package
Because writing the class is clumsy, most projects install react-error-boundary, which provides a ready-made component with a function-style API: <ErrorBoundary fallback={<p>Oops</p>} or FallbackComponent, a resetKeys prop that resets the boundary when some value changes, and an onError callback. The behaviour is the same as above.
React 19 error reporting
React 19 also lets you pass options to createRoot such as onCaughtError and onUncaughtError, central hooks to log every error that boundaries catch or that go unhandled. Use them to feed your monitoring tool in one place.
Suspense: a fallback while waiting
Some components are not ready to render yet because they are waiting for code (a lazily loaded component) or data (a Suspense-aware data source, such as use(promise) from the actions lesson). Suspense declares what to show in the meantime:
<Suspense fallback={<p>Loading...</p>}>
<Comments />
</Suspense>While Comments is waiting, React shows the fallback; when it is ready, React swaps it in. The component itself needs no loading flags: it can be written as if the data were there. You can nest boundaries: a page-level Suspense shows a skeleton for the whole page, and inner ones let parts appear as soon as they are ready. The closer the boundary is to the slow component, the smaller the area that shows a loader.
Lazy loading with React.lazy
A big app ships a lot of JavaScript. Users of the home page do not need the code of the admin page. Code splitting puts rarely used parts in separate files that download when needed. React.lazy does this for components: you give it a function that calls the dynamic import(), which the bundler (Vite) turns into a separate file:
import { lazy, Suspense } from "react";
const Settings = lazy(() => import("./Settings.jsx")); // Settings.jsx becomes its own file
function App() {
return (
<Suspense fallback={<p>Loading settings...</p>}>
<Settings />
</Suspense>
);
}The file Settings.jsx must have a default export. The first time <Settings /> renders, the browser downloads its file, lazy suspends in the meantime, and Suspense shows the fallback. Later renders use the already-loaded code. The live editor has only one file, so this example fakes the delay with a promise; the logic is identical:
import { lazy, Suspense, useState } from "react";
function Heavy() {
return <p>The heavy component has loaded.</p>;
}
// In a project: lazy(() => import("./Heavy.jsx"))
const LazyHeavy = lazy(() => new Promise(resolve => setTimeout(() => resolve({ default: Heavy }), 300)));
function App() {
const [show, setShow] = useState(false);
return (
<div>
<button onClick={() => setShow(true)}>Show heavy part</button>
{show && (
<Suspense fallback={<p>Loading...</p>}>
<LazyHeavy />
</Suspense>
)}
</div>
);
}
export default App;Show heavy part
The heavy component has loaded.After the click the fallback appears for 300 ms, then the component. In a Vite project, npm run build creates a separate small file for each lazy component (in the dist folder, next to the main bundle), and its name appears in the build output.
Where to split
The best split points are routes (each page is a lazy component, loaded when the user navigates there), big widgets that are rarely used (a rich-text editor, a chart library, a map), and modals that open on click. Do not split tiny components: each file costs a request and a little overhead. A router setup looks like this:
const Home = lazy(() => import("./pages/Home.jsx"));
const Admin = lazy(() => import("./pages/Admin.jsx"));
<Suspense fallback={<p>Loading page...</p>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>Boundaries together
Loading can fail (the network drops while downloading a chunk), so combine them: Suspense for waiting, an error boundary for failure.
<ErrorBoundary fallback={<p>Could not load this section.</p>}>
<Suspense fallback={<p>Loading...</p>}>
<Reports />
</Suspense>
</ErrorBoundary>Read it from the outside in: if anything below fails, show the error message; otherwise, while something below waits, show the loading text. This pairing around each independent section gives a UI that degrades gracefully instead of breaking.
Common mistakes
- Having no error boundary at all, so one small bug produces a blank page.
- Expecting a boundary to catch errors in click handlers or in
fetchcallbacks. - A single boundary at the top only, so any error replaces the whole page.
- Forgetting a
defaultexport in a file loaded withlazy. - Using
lazywithout a Suspense boundary above it (React throws). - Defining the
lazy(...)call inside a component, which creates a new lazy component on every render and reloads it; define it at the top level of the module. - Splitting every component, creating dozens of tiny requests.
- Swallowing the error: always log it somewhere you will see.
Exercise
- Wrap each of three widgets (weather, news, tasks) in its own
ErrorBoundary, make one throw, and check that the other two still work. - Add a "Report problem" button to the fallback that logs the error message.
- Lazy-load a
Chartcomponent only when the user presses "Show chart", and show aSkeletonas the fallback. - Make the retry button of the boundary also reset when a
userIdprop changes (hint: passkey={userId}to the boundary, or useresetKeysinreact-error-boundary).
Key points
- An error thrown while rendering unmounts the whole app unless an error boundary above it catches the error and shows a fallback.
- Error boundaries must be class components (or come from the react-error-boundary package); they do not catch errors in event handlers, async code or effects.
- Suspense shows a fallback while a child waits for code (lazy) or data (use, or a Suspense-aware library).
- React.lazy(() => import('./Page.jsx')) splits a component into its own file that downloads only when it is first rendered.