104: Error Boundaries, Suspense, Lazy Loading, and `use`
Learning objectives
You will learn to:
- distinguish render errors from event/network errors;
- contain render failures with Error Boundaries;
- use Suspense for loading boundaries;
- lazy-load component code with
lazy; - read supported promises/context with
use; - place boundaries according to user experience;
- understand retry/reset behavior;
- avoid treating Suspense as a universal fetch wrapper.
Two different boundary questions
When a UI depends on work that may not be finished yet, or on code that may fail, two separate questions need answers:
- What happens while something is not ready?
- What happens if rendering fails?
Suspense answers the first question. It supplies the pending UI while a supported rendering dependency is unavailable.
Error Boundaries answer the second. They contain render-time failures in a descendant tree and replace the failed region with fallback UI.
The two mechanisms often sit together, but they solve different problems. A loading fallback is not an error screen, and an Error Boundary is not a general-purpose way to represent pending work.
Error Boundaries
Consider a component that assumes a nested value is present:
function TaskDetails({ task }) {
return (
<p>
Owner:
{task.owner.profile.name}
</p>
);
}
If owner is unexpectedly null, the property access throws during rendering. React cannot produce the component's output for that render.
An Error Boundary can contain that failure. Instead of allowing the failed subtree to take down the whole visible application, the boundary renders fallback UI for the part of the tree it owns.
React still commonly expresses a custom Error Boundary as a class component:
import { Component } from 'react';
export class ErrorBoundary
extends Component {
constructor(props) {
super(props);
this.state = {
error: null,
};
}
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error, info) {
reportError(error, info);
}
render() {
if (this.state.error) {
return (
<section role="alert">
<h2>
This section could not load
</h2>
<p>
Try refreshing or return to
another page.
</p>
</section>
);
}
return this.props.children;
}
}
Use the boundary around the region that should fail independently:
<ErrorBoundary>
<TaskDetails task={task} />
</ErrorBoundary>
getDerivedStateFromError changes what the boundary renders, while componentDidCatch is the place to report diagnostic context. The fallback should be useful to the user, and the captured error should be useful to the team investigating the failure.
This lesson does not reintroduce class components as the preferred component style. Error Boundaries are a practical reason to recognize the class API, because the standard custom implementation still uses it.
Framework routers often provide their own route error boundaries as well. Those are usually a better fit for route-level loader, action, or rendering failures than duplicating the same responsibility in every page component.
What Error Boundaries do not catch
An Error Boundary is not a generic replacement for try/catch.
Expected failures still belong where the operation occurs, including:
- event handlers;
- mutation callbacks;
- API clients;
- validation logic.
For example:
async function handleSave() {
try {
await saveTask();
} catch (error) {
setSaveError(error);
}
}
The failed save is an expected action failure. The handler can translate it into form or mutation state. It is not a render failure for an Error Boundary to contain. The same distinction applies to ordinary server validation and rejected operations initiated by a user event.
Suspense mental model
Suspense can be understood as a rendering rule:
If a descendant is not ready to render, show this fallback until it can continue.
The boundary identifies the portion of the UI that may be replaced while the supported dependency is pending:
<Suspense
fallback={<TaskPanelSkeleton />}
>
<TaskPanel />
</Suspense>
Suspense is activated by supported sources such as:
lazycomponent code;- reading a promise with
use; - framework/server data integrations that support Suspense;
- server streaming boundaries.
It does not automatically observe arbitrary fetch calls started in Effects. Starting a request in useEffect and showing local loading state is a different data-loading design.
Lazy-loading component code
lazy turns a dynamic module import into a component that can suspend until its code is available:
import {
lazy,
Suspense,
} from 'react';
const AnalyticsPage = lazy(
() => import('./AnalyticsPage.jsx'),
);
export default function App() {
return (
<Suspense
fallback={<p>Loading analytics…</p>}
>
<AnalyticsPage />
</Suspense>
);
}
The dynamic import is requested when React first needs AnalyticsPage. Until the module resolves, the nearest Suspense fallback is eligible to render.
Do not declare lazy() inside a component:
function App() {
const Page = lazy(
() => import('./Page.jsx'),
);
return <Page />;
}
That expression creates a new component identity during renders. React may treat it as a different component, which can reset the subtree's state and make loading behavior unstable.
Declare lazy components at module scope so their identity is stable across renders.
Route-level code splitting
In a large application, route-level splitting often provides more value than splitting every small component. A route is a natural boundary because the user may never visit it, and the route's code can be kept out of the initial bundle.
A user who never visits Admin does not need all Admin code in the initial bundle. The trade-off is a later chunk request when the route is first entered, so the route needs an appropriate loading fallback and a plan for chunk-load failure.
Later React Router lessons show route lazy loading.
use
React's use API can read a supported Promise or Context during rendering.
Unlike Hooks, use can be called conditionally. That is a specific property of this API, not permission to call ordinary Hooks conditionally.
Promise example:
import {
Suspense,
use,
} from 'react';
function Comments({
commentsPromise,
}) {
const comments = use(
commentsPromise,
);
return (
<ul>
{comments.map((comment) => (
<li key={comment.id}>
{comment.body}
</li>
))}
</ul>
);
}
function Page({
commentsPromise,
}) {
return (
<Suspense
fallback={
<p>Loading comments…</p>
}
>
<Comments
commentsPromise={
commentsPromise
}
/>
</Suspense>
);
}
The Promise should be cached or otherwise reused. Creating a new Promise on every render creates a new dependency each time, which can cause repeated suspension and repeated requests.
use integrates closely with Server Components and framework data loading. Those integrations are covered in lesson 115, so the important point here is the boundary behavior: a supported pending Promise suspends, a fulfilled Promise supplies a value, and a rejected Promise becomes an error for the nearest Error Boundary.
Rejected promise
If a promise read with use rejects, React sends the error to the nearest Error Boundary.
That is why loading and error boundaries often appear next to one another:
<ErrorBoundary>
<Suspense
fallback={<TaskSkeleton />}
>
<TaskDetails />
</Suspense>
</ErrorBoundary>
While the promise is pending, Suspense supplies TaskSkeleton. If the promise rejects, the Error Boundary supplies its fallback. If it fulfills, TaskDetails renders its content.
Boundary placement
One boundary around the entire app is often too coarse:
<Suspense fallback={<FullPageSpinner />}>
<App />
</Suspense>
With this arrangement, any suspended descendant could replace the entire screen. That may be acceptable for an initial full-page load, but it is usually frustrating when a small panel is the only region waiting.
A better UX might keep navigation and other stable shell UI available:
<AppShell>
<Sidebar />
<ErrorBoundary>
<Suspense
fallback={
<MainPanelSkeleton />
}
>
<RouteContent />
</Suspense>
</ErrorBoundary>
</AppShell>
Here, the main content can load or fail without replacing the sidebar. The boundary location communicates which region has an independent loading and failure experience.
Boundary placement is product design:
- what can remain interactive?
- what should reveal together?
- what failure should be isolated?
- what loading fallback avoids layout shift?
There is no universally correct boundary location. Choose a region whose fallback and recovery behavior make sense to the person using the screen.
Avoid fallback flashing during updates
When existing content changes because of a non-urgent update, transitions can let React keep the current content visible while preparing the next screen. This avoids replacing a usable view with a broad fallback merely because the next view has a pending dependency.
This is covered in lesson 114.
Suspense and transitions are designed to cooperate. Suspense defines what can be shown while work is pending; a transition gives React permission to treat a navigation-like update as non-urgent and preserve current content where appropriate.
Empty is not loading
Pending data and successful empty data are different states. A useful model is:
pending
success + zero items
success + items
error
Do not render “No tasks” while data is still pending. That message describes a successful result with zero items, not an unresolved request.
The same distinction applies to Suspense fallbacks. A skeleton or loading message should describe waiting, not imply that the underlying resource is empty.
Error reset strategy
Once an Error Boundary has entered its fallback, it needs an intentional reset path before it can render the children again.
Possible strategies include:
- navigate away;
- change a boundary key;
- provide a retry that resets the boundary and retries the resource;
- use a router/framework error boundary with revalidation.
Do not create a Retry button that only clears the visible message while the underlying failing state remains unchanged. That produces the same error again, or worse, gives the user the appearance of recovery without retrying the failed code or data source.
Common mistakes
Suspense around Effect fetching
This will not suspend:
function Tasks() {
useEffect(() => {
fetch('/api/tasks')
.then(...)
}, []);
return ...;
}
The request starts in an Effect after rendering. Suspense does not detect Effect-based fetches, so this component needs its own loading and error state, or it must use a data source that explicitly integrates with Suspense.
Treating errors as loading
Do not leave a spinner visible after a request has definitively failed. Once failure is known, show an error state with an appropriate recovery action instead of making the user wait for work that will not succeed.
Too many tiny boundaries
A boundary around every icon produces noisy fallback behavior and extra complexity. The user sees fragmented pieces appear and disappear, while the code gains many independent recovery paths that may not have meaningful value.
Too few boundaries
One top-level fallback can erase useful working UI. If a single activity panel suspends, the navigation, header, and other usable panels should not necessarily disappear with it.
Exercises
- Lazy-load an Analytics route component with Suspense.
- Add an Error Boundary around a deliberately failing widget.
- Design a dashboard with shell-level and panel-level boundaries.
- Build a promise-reading example with
use. - Explain why a
fetchinsideuseEffectdoes not trigger Suspense. - Add a deliberate reset mechanism to an error boundary.
Exit questions
- What does Suspense wait for?
- What kinds of failures does an Error Boundary contain?
- Why should
lazy()be declared outside components? - How do
use, Suspense, and Error Boundaries interact? - Why is boundary placement a UX decision?
- Why is empty state different from loading state?
Official references
- https://react.dev/reference/react/Suspense
- https://react.dev/reference/react/lazy
- https://react.dev/reference/react/use
- https://react.dev/reference/react/Component
Deep dive: Suspense is a coordination boundary, not a spinner component
A boundary such as this one is more than a convenient spinner wrapper:
<Suspense fallback={<TaskSkeleton />}>
<TaskPanel />
</Suspense>
It coordinates descendants that suspend. In practical terms, it defines:
- which existing UI may be replaced;
- what pending UI appears;
- what content reveals together;
- where transitions can preserve current content.
Think in user-perceived regions, not implementation files. Two components in different files may belong in one boundary if the user experiences them as one unit. Conversely, one file may contain regions that should load and fail independently.
Nested Suspense
Nested boundaries let a page reveal meaningful pieces at different times:
<Suspense fallback={<PageSkeleton />}>
<ProfileHeader />
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</Suspense>
If ActivityFeed is slow, the header can reveal first once it is ready. The inner boundary limits the pending replacement to the activity region rather than hiding the entire page.
Too many tiny boundaries produce flashing, fragmented UI. Too few boundaries replace large usable areas. Design the boundaries around the experience you want the user to have, then place them around the corresponding component tree.
Lazy loading and retries
const Reports = lazy(() => import('./Reports.jsx'));
Lazy loading has a failure mode in addition to its bundle benefit. The chunk-loading promise can reject if:
- deployment changed chunks;
- network dropped;
- cache references old asset;
When it rejects, the nearest Error Boundary handles the error. The Suspense fallback is only the pending state; it is not the permanent response to a failed chunk.
A production app should decide:
- retry?
- full reload?
- user message?
- deployment version mismatch handling?
Code splitting introduces operational decisions as well as smaller initial bundles. A retry can help with a transient network failure, while a full reload may be appropriate when the browser has references to assets from an older deployment. The choice belongs in the application's recovery design.
Preloading code
React, platform, and framework APIs may allow an application to preload modules before navigation when the user's intent is known.
For example, hovering or focusing a link can be a signal to prefetch code or data. This can move work earlier and reduce the delay after activation, but it also spends bandwidth before the user has committed to the route.
Do not preload every route. Balance:
- probability user visits;
- chunk size;
- network constraints.
Error Boundary granularity
Possible layers include:
App boundary
Route boundary
Widget boundary
Editor boundary
Do not put one boundary around every button. A button normally does not have an independent rendering product experience, and an event failure should be handled by the action that produced it anyway.
Good boundary candidates have:
- independent user value;
- independent failure mode;
- meaningful fallback/retry.
For example, a dashboard can isolate an activity feed from the task board:
<DashboardLayout>
<TaskBoard />
<ErrorBoundary fallback={<ActivityError />}>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</ErrorBoundary>
</DashboardLayout>
If the activity feed fails, the task board remains usable. That is a meaningful boundary because the two regions provide separate value and can have separate pending and recovery UI.
Error Boundary state reset
A boundary that caught an error remains in its fallback until it is reset or remounted.
One simple reset is to key the boundary by the record being viewed:
<ErrorBoundary key={taskId}>
<TaskDetails taskId={taskId} />
</ErrorBoundary>
Changing the record creates a new boundary identity. This is useful when moving from one task to another should give the new task a clean rendering attempt.
For Retry on the same task, a boundary implementation or library can expose a reset function. That reset must be coordinated with the resource as well.
Retry must also reset or retry the failing data/code source. Remounting a boundary without changing the rejected Promise or failed chunk condition does not create a genuine recovery attempt.
Expected errors versus exceptional rendering errors
Expected validation such as:
422 title already exists
belongs in form state. The user can correct the title, and the form should explain the validation failure near the relevant control.
An expected mutation conflict such as:
409 record changed
belongs in the mutation workflow. The application may ask the user to refresh, reconcile changes, or retry according to the operation's semantics.
An unexpected render failure such as:
Cannot read properties of undefined
belongs to Error Boundary handling and monitoring. It indicates that the component could not produce valid output under the current state.
Do not use Error Boundaries to handle ordinary server validation. Doing so loses the distinction between a user-correctable operation result and an exceptional rendering defect.
Promise reading with use
React use can read a Promise during rendering:
function Product({ productPromise }) {
const product = use(productPromise);
return <h1>{product.name}</h1>;
}
The result maps cleanly to the surrounding boundaries:
If pending:
nearest Suspense fallback
If rejected:
nearest Error Boundary
If fulfilled:
render data
This creates a clean composition model when promises come from a supported framework or cache architecture. The component can describe what it needs, while the surrounding tree decides how pending and failed states should appear.
Promise identity matters
This pattern is problematic:
function Product({ id }) {
const product = use(fetch(`/api/products/${id}`).then(r => r.json()));
...
}
Every render creates a new Promise and a new request. React may see a different pending dependency on every pass, causing repeated suspension and potentially repeated network work.
Use framework/cache-managed promises or stable cached resources instead. React documentation warns that promises created in client components without caching are problematic because their identity is not stable across renders.
use with Context
use can also read Context, including conditionally:
function Heading({ showTheme }) {
if (showTheme) {
const theme = use(ThemeContext);
return <h2 className={theme}>...</h2>;
}
return <h2>...</h2>;
}
This is different from ordinary Hooks, which cannot be called conditionally. The conditional call is an exception provided by use for reading supported resources such as Context.
Do not generalize this exception to useState or useEffect. Those Hooks still follow the Rules of Hooks and must be called consistently at the top level of the component.
Suspense does not catch event promises
This does not cause the nearest Suspense fallback to appear:
async function handleClick() {
await saveTask();
}
The Promise belongs to an event/action operation, not to a rendering dependency being read by a descendant. Its pending and failure states need to be represented by the operation's own workflow, for example:
- local mutation state;
- React Action;
- TanStack Query mutation;
- transition.
Suspense is about rendering dependencies, not every Promise in application code. Treating every asynchronous operation as Suspense work would make it impossible to choose sensible feedback for independent mutations, saves, and user actions.
Suspense and transitions
Suppose the user is viewing Tab A and switches to Tab B, whose content suspends.
Without a transition, the fallback may replace the current content immediately. The user can lose a usable view while React waits for the next one.
With a transition, React can keep old content visible while preparing the next content, depending on the boundary behavior. This makes the update feel like a navigation that is in progress rather than a blank replacement.
This is covered deeply in performance/concurrency, but the mental model starts here: boundaries control the pending region, and transitions influence whether current content can remain visible during a non-urgent update.
Error logging
componentDidCatch(error, info) can send error context to monitoring.
Do not:
- expose stack traces to users;
- log secrets;
- assume every error is safe to serialize.
Include stable release and route context where useful. Component-stack information and deployment version help correlate a production failure with the code that rendered it, while careless logging can expose credentials or sensitive user data.
React Router integration
Modern routers often provide route error boundaries and pending/loading patterns.
Use route-level error handling for loader/action/route render failures and component boundaries for independent widgets. The route layer understands navigation and data revalidation; a widget boundary understands the smaller region that can fail without taking the route down.
Do not duplicate the same error handling in:
router errorElement
component state
global toast
ErrorBoundary
without clear responsibility. Duplicate handlers can produce multiple messages for one failure and make it unclear which layer owns retry, logging, and recovery.
Failure clinic
Suspense wrapped around Effect fetch
No suspension. The Effect request is not a supported Suspense dependency, so the surrounding fallback does not track it.
Lazy component declared in render
Identity resets and code-load semantics become unstable. Move the lazy declaration to module scope.
Fallback with no size
Large layout shift. A fallback that reserves approximately the final region's space gives the page more stable geometry.
A skeleton that approximates final layout can improve perceived stability.
Error Boundary catches nothing
The error occurred in an event handler. Handle expected event errors where they occur; an Error Boundary is for errors thrown while rendering its descendant tree.
Deep-dive exercises
- Design nested Suspense boundaries for a dashboard.
- Force a lazy import failure and route it to Error Boundary.
- Implement boundary reset keyed by record ID.
- Separate 422 form failure from render exception.
- Build a cached Promise +
useexample. - Compare fallback behavior with and without transition.
Mastery check
Explain:
- what can trigger Suspense;
- what Error Boundaries catch;
- how
useinteracts with pending/rejected promises; - why promise identity/cache matters;
- why boundary placement is UX architecture;
- how expected server errors differ from render exceptions.
Production case study: route shell with independent slow regions
Imagine an order dashboard with these regions:
Navigation
Order summary
Kitchen activity
Customer timeline
A useful boundary layout keeps the shell and each independent data region separate:
<AppShell>
<OrderHeader />
<ErrorBoundary fallback={<OrderSummaryError />}>
<Suspense fallback={<OrderSummarySkeleton />}>
<OrderSummary />
</Suspense>
</ErrorBoundary>
<div className="dashboard-columns">
<ErrorBoundary fallback={<KitchenError />}>
<Suspense fallback={<KitchenSkeleton />}>
<KitchenActivity />
</Suspense>
</ErrorBoundary>
<ErrorBoundary fallback={<TimelineError />}>
<Suspense fallback={<TimelineSkeleton />}>
<CustomerTimeline />
</Suspense>
</ErrorBoundary>
</div>
</AppShell>
If Customer Timeline fails, Kitchen still works. The timeline's error is contained to the region that owns it.
If Kitchen is slow, Order Header remains useful. The user can still orient themselves and use whatever other regions have already resolved.
Boundary review questions
For each boundary, ask:
What can fail?
What can suspend?
What remains usable?
What retry action exists?
What size should fallback reserve?
What telemetry should be recorded?
These questions turn Suspense and Error Boundaries from syntax into resilience architecture. They force the implementation to account for the pending state, failure scope, recovery behavior, layout stability, and operational visibility of each region.
Additional depth: recovery design and fallback quality
A fallback is part of the product, not placeholder boilerplate. Users rely on it to understand whether the application is still working, whether their data is safe, and what they can do next.
Loading fallback quality
Good fallback should:
- preserve approximate layout;
- avoid fake interactive controls;
- use appropriate
aria-busy/status semantics; - avoid announcing dozens of skeleton nodes;
- not imply empty data.
A loading state should communicate waiting without pretending that placeholder content is real data. Preserving the region's approximate dimensions also reduces layout shift, while appropriate accessibility semantics keep assistive technology from receiving a noisy announcement for every skeleton element.
Error fallback quality
Good error fallback should answer:
What failed?
What remains safe?
Can I retry?
Will retry duplicate anything?
Where can I go instead?
For example:
function ActivityFeedError({ retry }) {
return (
<section role="alert">
<h2>Activity is unavailable</h2>
<p>Your task changes are still safe.</p>
<button type="button" onClick={retry}>
Try activity again
</button>
</section>
);
}
This is better than:
<p>Something went wrong.</p>
because it tells the user the failure scope. It also gives them a concrete recovery action and clarifies that the task changes are not part of the unavailable activity feed. The retry implementation still needs to reset both the boundary and the failed source, and it must be safe for the operation being retried.
Boundary telemetry
Capture:
route
feature
release
component stack
correlation ID
but not sensitive form payloads. These fields provide enough context to group and investigate failures without turning error reporting into an accidental data-exfiltration path.
Boundary test matrix
Test the complete state progression:
child suspends → loading fallback
child resolves → content
child rejects → error fallback
retry succeeds
route changes → boundary resets if intended
one sibling fails → other sibling remains
Resilience is not complete until boundary behavior is tested. A component that renders correctly on the happy path can still have a broken fallback, an ineffective retry, or a boundary that hides unrelated working UI.
