Learning path · Level 10 · Tutorial 7

Contain React Rendering Failures with Error Boundaries

Contain unexpected React rendering failures so one broken feature does not remove the entire application. Provide an accessible fallback and recovery behavior that distinguishes resetting the boundary from correcting the cause.

  • Level: Intermediate
  • Duration: 20 min
  • 8 steps
Start tutorial Back to the path
Contain React Rendering Failures with Error Boundaries

What you will go through

  1. Recognize What an Error Boundary Contains Decide which unexpected failures an error boundary contains and which conditions need explicit handling elsewhere. 2 min
  2. Protect the Routed Feature, Not the Whole Shell Add a typed error boundary around project details while preserving usable application navigation. 3 min
  3. Trigger and Observe a Contained Render Failure Trigger a deterministic descendant rendering failure in Project Workspace and inspect the visible containment locally. 2 min
  4. Repair the Cause Before Retrying Repair a parent-owned simulated failure source before explicitly retrying an error boundary. 3 min
  5. Make the Fallback Usable by Keyboard Make failed project content understandable and keyboard-accessible by focusing the fallback after replacement and the detail heading after successful recovery. 3 min
  6. Recover When the Relevant Route Input Changes Use a stable route parameter as a reset input while preserving the distinction between retrying a boundary and repairing the failure source. 2 min
  7. Trace Which State Survives Recovery Trace local, shared, and route-owned state through an error boundary fallback and a successful remount. 2 min
  8. Verify the Complete Failure and Recovery Flow Verify a contained, accessible error-boundary recovery flow in the completed local Project Workspace application. 3 min

What you will learn

  • Classify failures that an error boundary catches and failures that require explicit handling elsewhere.
  • Wrap a routed feature with an error boundary while keeping navigation usable.
  • Trigger a controlled render failure locally and check the fallback's message, keyboard access, and focus behavior.
  • Recover through an explicit reset after correcting the cause and through a relevant route-input change.
  • Explain which component state is lost or preserved when failed content remounts.

Before you start

  • Organize React Features by Responsibility
  • Share Scoped Client State with Context
  • Represent Detail Views and Filters in the URL
  • Focus Native Controls with React Refs
  • Handle Failures with JavaScript Exceptions

Step 1 of 8

Recognize What an Error Boundary Contains

Decide which unexpected failures an error boundary contains and which conditions need explicit handling elsewhere.

Contain the failed descendant

A contained rendering failure

An error boundary contains an unexpected failure that occurs while React renders a descendant or performs React-managed lifecycle work. Instead of leaving that descendant subtree on screen, React shows the boundary’s fallback.

The boundary is not a general JavaScript catch-all. Its scope is specifically React’s managed rendering work below it. Content outside the failed descendant subtree can remain available.

What React replaces

A component tree where a failed descendant feature inside an error boundary is replaced by fallback content while a sibling sidebar remains visible.

A boundary replaces its failed descendant subtree with fallback content; it does not remove unrelated content outside that subtree.

Tip

Scope has a limit

Think in terms of ownership: a boundary can contain a descendant’s render failure, but it cannot contain a failure that happens in the boundary’s own rendering or in its fallback. An enclosing error boundary could contain that later failure.

Separate element creation from rendering

JSX creation is not child rendering

A JSX expression creates a React element description; it does not immediately call the child component. React renders that child later. Therefore, a try/catch around return <Child /> does not catch an exception thrown while React subsequently renders Child.

Why this catch does not contain Child

A tempting try/catch

tsx
function Parent() {
  try {
    return <Child />;
  } catch {
    return <p>Child could not render.</p>;
  }
}

function Child() {
  throw new Error('Render failed');
}

Example

Later React work

Parent returns a description that says to render Child. React later enters Child, where the exception occurs—after the try block has completed. Use an error boundary for this descendant rendering case rather than relying on this try/catch.

Handle expected states explicitly

Classify by where and why the failure occurs

Choose explicit handling when the condition is an expected part of the feature’s normal behavior. Do not deliberately turn expected states into render crashes just to show a fallback.

Boundary or explicit feature state?

Use this distinction when deciding whether a boundary is the right tool.

Expected interface states need their own intentional UI; unexpected descendant rendering failures can be contained by a boundary.
SituationAppropriate response
Descendant throws while renderingError boundary fallback
Click handler throwsHandle at the event source
Request promise rejectsRequest-error state and recovery
Validation does not passValidation feedback
No matching recordsEmpty-result state
Fallback itself throwsAn enclosing boundary may contain it

Practice: choose the right containment

Classify the cases

Match each situation to the response or explanation that fits it best.

Boundary scope check

Match every situation with its correct handling or explanation.

Tap an item and then its matching pair.

Step 2 of 8

Protect the Routed Feature, Not the Whole Shell

Add a typed error boundary around project details while preserving usable application navigation.

Set up a contained feature boundary

Choose a narrow boundary scope

An error boundary should protect a feature that can fail independently—not the whole application shell. In this lab, project details are protected so a failed detail view can be replaced by a fallback while navigation remains available.

Use your own Vite React TypeScript application. Every source file and local record required for this Project Workspace lab appears in this step; no external assets are needed.

Boundary scope in Project Workspace

A component ownership diagram with persistent navigation outside a protected project-detail area and a state owner above the error boundary.

The shell and navigation remain mounted. ProjectRoute owns blockedProjectId above the error boundary, which protects ProjectDetails.

Install the local dependencies

Run this command from the root of your local Vite React TypeScript application.

bash
npm install react-error-boundary react-router
Expected output
Installation completes successfully.
No npm ERR! message appears.

Package, audit, and timing lines vary with your existing project and npm version.

Keep routing and navigation outside the feature

Create the entry point and shell

Keep BrowserRouter above the application shell. The shell owns persistent navigation, and all route content belongs inside <main>. Because these links sit outside the feature boundary added later, users retain a route away from an unavailable project.

src/main.tsx

src/main.tsx

tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>
);

src/App.tsx

src/App.tsx

tsx
import { Link, Route, Routes } from 'react-router';
import { ProjectRoute } from './features/projects/ProjectRoute';

function ProjectsOverview() {
  return (
    <section>
      <h1>Projects</h1>
      <p>Select Atlas or Beacon to view its details.</p>
    </section>
  );
}

export function App() {
  return (
    <div>
      <header>
        <nav aria-label='Primary'>
          <Link to='/projects'>Projects</Link>{' '}
          <Link to='/projects/alpha'>Atlas</Link>{' '}
          <Link to='/projects/beta'>Beacon</Link>
        </nav>
      </header>
      <main>
        <Routes>
          <Route path='/projects' element={<ProjectsOverview />} />
          <Route path='/projects/:projectId' element={<ProjectRoute />} />
        </Routes>
      </main>
    </div>
  );
}

Add the route-level error boundary

Compose the protected project route

ProjectRoute composes the routed feature. It reads the project parameter, keeps the small local record collection, and owns blockedProjectId above the error boundary. The source starts as null, so this step does not create a failure.

An unknown identifier is expected route data. Render ordinary not-found content for it rather than converting it into a rendering failure. The boundary surrounds only ProjectDetails, and FallbackComponent={FeatureFallback} connects the typed fallback.

src/features/projects/ProjectRoute.tsx

src/features/projects/ProjectRoute.tsx

tsx
import { useState } from 'react';
import { useParams } from 'react-router';
import { ErrorBoundary } from 'react-error-boundary';
import { FeatureFallback } from './FeatureFallback';
import { ProjectDetails } from './ProjectDetails';

type Project = { id: string; name: string };

const projects: Project[] = [
  { id: 'alpha', name: 'Atlas' },
  { id: 'beta', name: 'Beacon' }
];

export function ProjectRoute() {
  const { projectId } = useParams();
  const [blockedProjectId] = useState<string | null>(null);
  const project = projects.find((item) => item.id === projectId);

  if (!projectId || !project) {
    return <h1 tabIndex={-1}>Project not found</h1>;
  }

  return (
    <ErrorBoundary FallbackComponent={FeatureFallback}>
      <ProjectDetails
        project={project}
        shouldCrash={blockedProjectId === projectId}
      />
    </ErrorBoundary>
  );
}

src/features/projects/FeatureFallback.tsx

src/features/projects/FeatureFallback.tsx

tsx
import { Link } from 'react-router';
import type { FallbackProps } from 'react-error-boundary';

export function FeatureFallback(_props: FallbackProps) {
  return (
    <section>
      <h1 tabIndex={-1}>Project unavailable</h1>
      <p>We could not display this project.</p>
      <Link to='/projects'>Back to projects</Link>
    </section>
  );
}

src/features/projects/ProjectDetails.tsx

src/features/projects/ProjectDetails.tsx

tsx
type Project = { id: string; name: string };

type ProjectDetailsProps = {
  project: Project;
  shouldCrash: boolean;
};

export function ProjectDetails({
  project,
  shouldCrash
}: ProjectDetailsProps) {
  void shouldCrash;

  return (
    <section>
      <h1 tabIndex={-1}>{project.name}</h1>
      <p>Project ID: {project.id}</p>
    </section>
  );
}

Tip

Keep the source owner above the boundary

blockedProjectId is deliberately owned by ProjectRoute, outside the error boundary. If the protected descendant is later replaced by a fallback, this parent owner remains mounted. The fallback is typed with the library’s FallbackProps; no class component is required.

Check the boundary connection

Connect the boundary without widening its scope

Complete the protected route composition. Keep the error boundary around the detail component, connect its typed fallback, and pass the failure-related value down from the parent-owned state.

tsx
return (
  <ErrorBoundary ={FeatureFallback}>
    <ProjectDetails
      project={project}
      
    />
  </ErrorBoundary>
);

Step 3 of 8

Trigger and Observe a Contained Render Failure

Trigger a deterministic descendant rendering failure in Project Workspace and inspect the visible containment locally.

See the boundary's scope

From click to contained fallback

A controlled failure begins with an ordinary click. The button asks ProjectRoute to update its parent-owned blockedProjectId state. React then renders ProjectDetails again with shouldCrash set to true.

The throw happens during that later descendant render—not inside the click handler. The error boundary can therefore replace only its failed child area with its fallback. The shell and its navigation are outside that boundary.

What the boundary replaces

Diagram showing a detail button requesting a parent state update, followed by an error boundary replacing only the project detail area while the application navigation remains visible.

The boundary replaces its failed descendant content. The shell remains outside the failed subtree.

Request the failure, then throw while rendering

Add a deterministic trigger

Update the two feature files below. blockedProjectId stays in ProjectRoute, above ErrorBoundary, so the button can request a state change that leads to a new descendant render. Keep the ordinary not-found branch: an unknown URL is expected content, not a render crash.

src/features/projects/ProjectRoute.tsx

tsx
import { useState } from 'react';
import { useParams } from 'react-router';
import { ErrorBoundary } from 'react-error-boundary';
import { FeatureFallback } from './FeatureFallback';
import { ProjectDetails } from './ProjectDetails';

type Project = { id: string; name: string };

const projects: Project[] = [
  { id: 'alpha', name: 'Atlas' },
  { id: 'beta', name: 'Beacon' }
];

export function ProjectRoute() {
  const { projectId } = useParams();
  const [blockedProjectId, setBlockedProjectId] = useState<string | null>(null);
  const project = projects.find((item) => item.id === projectId);

  if (!projectId || !project) {
    return <h1 tabIndex={-1}>Project not found</h1>;
  }

  return (
    <ErrorBoundary FallbackComponent={FeatureFallback}>
      <ProjectDetails
        project={project}
        shouldCrash={blockedProjectId === projectId}
        onSimulateFailure={() => setBlockedProjectId(projectId)}
      />
    </ErrorBoundary>
  );
}
Expected output
After activating Simulate render failure on Atlas:

Project unavailable
We could not display this project.
Back to projects

The shell navigation remains visible:
Projects  Atlas  Beacon

After you save both feature files, open `/projects/alpha` and activate the button rendered by ProjectDetails. The visible result is shown below; development diagnostics can vary.

src/features/projects/ProjectDetails.tsx

tsx
import { useState } from 'react';

type Project = { id: string; name: string };

interface ProjectDetailsProps {
  project: Project;
  shouldCrash: boolean;
  onSimulateFailure: () => void;
}

export function ProjectDetails({
  project,
  shouldCrash,
  onSimulateFailure
}: ProjectDetailsProps) {
  const [draft, setDraft] = useState('');

  if (shouldCrash) {
    throw new Error('Simulated project render failure');
  }

  return (
    <section>
      <h1 tabIndex={-1}>{project.name}</h1>
      <label>
        Local draft
        <input
          value={draft}
          onChange={(event) => setDraft(event.currentTarget.value)}
        />
      </label>
      <button type='button' onClick={onSimulateFailure}>
        Simulate render failure
      </button>
    </section>
  );
}
Expected output
Before activation, the Atlas detail is visible with a Local draft input and a Simulate render failure button.

After activation, the project-detail area becomes:

Project unavailable
We could not display this project.
Back to projects

The shell navigation remains visible:
Projects  Atlas  Beacon

Run the local app, open `/projects/alpha`, and activate Simulate render failure. Inspect the application after dismissing any development overlay.

Tip

Keep Hook calls before the throw

The Hook is called before the conditional throw. That keeps Hook calls in the same order on every render, so this example isolates the descendant rendering failure you intend to study.

Check the throw location

Complete the render-time condition

Complete the condition so this component throws only when it receives the crash signal.

tsx
const [draft, setDraft] = useState('');

if () {
  throw new Error('Simulated project render failure');
}

Observe the contained result

Warning

Run and inspect the application

Open /projects/alpha in your local app and activate Simulate render failure. Development mode may log the error more than once, and a development overlay may appear. If an overlay appears, dismiss it before judging the rendered application. Console output or diagnostics alone do not show whether containment worked.

This trigger is a local learning aid that makes the behavior repeatable. Do not treat an intentional render crash as a production recovery design.

Report the visible containment

After completing the trigger and running it locally, report what interface region was replaced after activation and name one navigation link that you could still use. Base your answer on the page after dismissing any development overlay.

Write at least 60 characters (0/60).

Step 4 of 8

Repair the Cause Before Retrying

Repair a parent-owned simulated failure source before explicitly retrying an error boundary.

Understand the recovery sequence

A reset is not a repair

A reset asks an error boundary to try rendering its children again. It does not repair the state, input, or code that made a descendant throw.

Here, ProjectRoute still owns blockedProjectId above the boundary. If it still matches the current projectId, the next ProjectDetails render receives shouldCrash={true}, throws again, and the fallback returns.

A reset is useful only when something relevant has changed before the retry. For this controlled example, the explicit recovery action will clear the recoverable failure source first.

Diagram showing ProjectRoute owning blockedProjectId outside an error boundary and clearing it before a retry can render ProjectDetails successfully.

The owner outside the boundary can repair the failure source before the boundary retries its child.

Warning

Avoid automatic retry loops

Do not reset automatically from an Effect or timer. If the cause remains, repeated retries only create a reset loop.

Likewise, a reset button cannot repair a deterministic code defect. Correct the defective code, deploy the correction, or offer a different safe path; then a retry can be meaningful.

Connect explicit reset to source repair

Let the fallback request a retry

Update FeatureFallback.tsx so its button makes an explicit reset request. The fallback requests recovery; it does not own or modify blockedProjectId. Keep the escape link available.

FeatureFallback.tsx

src/features/projects/FeatureFallback.tsx

tsx
import { Link } from 'react-router';
import type { FallbackProps } from 'react-error-boundary';

export function FeatureFallback({
  resetErrorBoundary
}: FallbackProps) {
  return (
    <section>
      <h1>Project unavailable</h1>
      <p>This project cannot be displayed right now.</p>
      <button type='button' onClick={resetErrorBoundary}>
        Try again
      </button>
      <Link to='/projects'>Back to projects</Link>
    </section>
  );
}

Repair the parent-owned source before retrying

In ProjectRoute, handle the reset where blockedProjectId is owned. onReset runs before the boundary tries its children again. For this step, repair state only for an explicit button-driven reset; other reset reasons are deliberately left alone.

ProjectRoute boundary composition

Replace the existing ErrorBoundary opening tag and its child in src/features/projects/ProjectRoute.tsx with this composition. Keep your existing records and not-found branch unchanged.

tsx
<ErrorBoundary
  FallbackComponent={FeatureFallback}
  onReset={(details) => {
    if (details.reason === 'imperative-api') {
      setBlockedProjectId(null);
    }
  }}
>
  <ProjectDetails
    project={project}
    shouldCrash={blockedProjectId === projectId}
    onSimulateFailure={() => setBlockedProjectId(projectId)}
  />
</ErrorBoundary>

Practice the onReset condition

Complete the explicit repair

Complete the condition so onReset clears the simulated failure source only for a button-driven explicit reset.

tsx
onReset={(details) => {
  if (details.reason === '') {
    setBlockedProjectId(null);
  }
}}

Apply the full recovery sequence

Order the recovery flow

Arrange the successful explicit recovery flow. Use this sequence to predict why a reset-only attempt fails when the source-clear step is missing.

  1. ProjectDetails receives shouldCrash={false} and renders its project content.
  2. The error boundary retries ProjectDetails.
  3. ProjectRoute handles the explicit reset and clears blockedProjectId.
  4. FeatureFallback calls resetErrorBoundary().
  5. The user activates Try again in FeatureFallback.

Step 5 of 8

Make the Fallback Usable by Keyboard

Make failed project content understandable and keyboard-accessible by focusing the fallback after replacement and the detail heading after successful recovery.

Place focus in the replacement content

Give focus a new destination

When a render failure replaces project details, the control that triggered it disappears with that subtree. Keyboard users need a deliberate new position in the replacement content.

The fallback should explain the unavailable project in plain language, offer Try again, and provide Back to projects. Do not expose the raw exception message: it does not guide recovery and may reveal implementation details.

After the fallback commits, move focus to its heading. tabIndex={-1} allows programmatic focus without adding the heading to the normal Tab sequence. Keep the button and link native: no positive tabIndex, keyboard trap, or custom keyboard activation is needed.

Focus moves with the displayed feature state

Diagram showing stable application navigation outside a replaced project feature, with focus moving to a fallback heading after failure and to a detail heading after recovery.

The shell remains usable. Each displayed feature state has one deliberate heading-focus destination.

Implement deliberate focus destinations

Focus the fallback heading after it mounts

Replace src/features/projects/FeatureFallback.tsx with this version. Its Effect runs after the fallback is committed, so the new heading exists before it receives focus. The native retry button requests the explicit recovery already connected in ProjectRoute.

Implement the accessible fallback

src/features/projects/FeatureFallback.tsx

tsx
import { useEffect, useRef } from 'react';
import { Link } from 'react-router';
import type { FallbackProps } from 'react-error-boundary';

export function FeatureFallback({
  resetErrorBoundary
}: FallbackProps) {
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    headingRef.current?.focus();
  }, []);

  return (
    <section aria-labelledby='project-unavailable-heading'>
      <h1
        id='project-unavailable-heading'
        ref={headingRef}
        tabIndex={-1}
      >
        Project unavailable
      </h1>
      <p>This project cannot be displayed right now.</p>
      <button type='button' onClick={resetErrorBoundary}>
        Try again
      </button>
      <Link to='/projects'>Back to projects</Link>
    </section>
  );
}
Expected output
Visible fallback:
Project unavailable
This project cannot be displayed right now.
Try again
Back to projects

Focus: the Project unavailable heading is focused.
Press Tab: Try again receives focus.
Press Tab again: Back to projects receives focus.

This is expected browser behavior, not terminal output. It appears after the controlled render failure replaces the project details.

Focus the restored detail after recovery

src/features/projects/ProjectDetails.tsx

tsx
import { useEffect, useRef, useState } from 'react';

type Project = { id: string; name: string };

interface ProjectDetailsProps {
  project: Project;
  shouldCrash: boolean;
  onSimulateFailure: () => void;
}

export function ProjectDetails({
  project,
  shouldCrash,
  onSimulateFailure
}: ProjectDetailsProps) {
  const [draft, setDraft] = useState('');
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    headingRef.current?.focus();
  }, [project.id]);

  if (shouldCrash) {
    throw new Error('Simulated project render failure');
  }

  return (
    <section aria-labelledby='project-details-heading'>
      <h1
        id='project-details-heading'
        ref={headingRef}
        tabIndex={-1}
      >
        {project.name}
      </h1>
      <label>
        Local draft
        <input
          value={draft}
          onChange={(event) => setDraft(event.currentTarget.value)}
        />
      </label>
      <button type='button' onClick={onSimulateFailure}>
        Simulate render failure
      </button>
    </section>
  );
}
Expected output
Visible detail after a successful explicit recovery:
Atlas
Local draft
Simulate render failure

Focus: the restored Atlas heading is focused.

This is expected browser behavior. A successful explicit reset mounts the detail again and focuses its heading.

Tip

Keep one focus owner per displayed state

Focus ownership stays local to the displayed feature state: FeatureFallback focuses only its fallback heading, and ProjectDetails focuses only its successful detail heading. Do not add a shell-level Effect that also focuses a heading on route changes; it could steal focus from either state.

Keep route content inside the shell’s <main> as before.

Practice the focus mechanism

Complete fallback focus handling

Complete the fallback heading’s focus handling.

tsx
const headingRef = useRef<HTMLHeadingElement>(null);

useEffect(() => {
  headingRef.current?.();
}, []);

return (
  <h1 ref={headingRef} tabIndex={}>
    Project unavailable
  </h1>
);

Check the complete local interaction

Verify the keyboard recovery flow

Run the updated local application and use only the keyboard for this check.

  1. Open /projects/alpha, Tab to Simulate render failure, and activate it.
  2. Record where focus lands. Press Tab twice and record the two fallback actions reached.
  3. Activate Back to projects and record the navigation result.
  4. Use the existing Atlas navigation link to return to the project. Trigger the failure again, activate Try again, and record where focus lands after successful recovery.

Report the observed focus destinations, keyboard access, and navigation behavior. If a result differs, identify which displayed state did not receive its intended focus.

Write at least 120 characters (0/120).

Step 6 of 8

Recover When the Relevant Route Input Changes

Use a stable route parameter as a reset input while preserving the distinction between retrying a boundary and repairing the failure source.

Reset for the route input that matters

A meaningful input can reset the boundary

After an error boundary has switched to its fallback, the boundary can remain mounted while the route parameter changes. Without a reset signal, it may keep displaying the fallback even though the next project is healthy.

Give the boundary the route input that matters: resetKeys={[projectId]}. When that primitive value changes, the existing boundary resets and tries its children again. This is a retry of the same boundary instance—not a replacement of the boundary.

Route-input recovery

Diagram showing a stable ProjectRoute and one stable error boundary while its failed alpha content changes to a healthy beta detail after the route input changes.

ProjectRoute stays above the same boundary. A relevant route-input change asks that boundary to retry its child content.

Tip

Keep the reset input stable

An inline array is appropriate here because its entry is a stable primitive value. The array itself is new on each render, but the boundary compares its entries.

Do not use a fresh object as a reset entry, and do not add key={projectId} to the boundary. Either choice can hide the intended behavior by causing unnecessary resets or by replacing the boundary instead of exercising resetKeys.

Separate input change from explicit repair

Keep one boundary instance

In src/features/projects/ProjectRoute.tsx, add the reset input to the existing boundary. Keep the repair in onReset limited to an explicit user retry.

tsx
<ErrorBoundary
  FallbackComponent={FeatureFallback}
  onReset={({ reason }) => {
    if (reason === 'imperative-api') {
      setBlockedProjectId(null);
    }
  }}
  resetKeys={[projectId]}
>
  <ProjectDetails
    project={project}
    shouldCrash={blockedProjectId === projectId}
    onSimulateFailure={() => setBlockedProjectId(projectId)}
  />
</ErrorBoundary>

Do not repair the wrong cause

A reset-key change has a different purpose from clicking Try again. Moving from a failed project to another project should retry for the new input, but it should not erase the stored cause for the old input.

Follow the route sequence

Expected behavior with blockedProjectId still set to alpha:

The boundary persists while its relevant input changes.
ActionBoundary resultStored failure sourceFocus destination
Fail AtlasFallback is shownalphaFallback heading
Navigate to BeaconRetries and shows detailsalpha remainsBeacon detail heading
Return to AtlasRetries, then fails againalpha remainsFallback heading
Click Try again on AtlasRepairs, then remounts detailsclearedAtlas detail heading

Practice the boundary configuration

Connect the two recovery paths

Complete the reset-reason guard and the relevant route input.

tsx
<ErrorBoundary
  FallbackComponent={FeatureFallback}
  onReset={({ reason }) => {
    if (reason === '') {
      setBlockedProjectId(null);
    }
  }}
  resetKeys={[]}
>
  <ProjectDetails
    project={project}
    shouldCrash={blockedProjectId === projectId}
    onSimulateFailure={() => setBlockedProjectId(projectId)}
  />
</ErrorBoundary>

Predict recovery across route changes

Predict the complete route sequence

blockedProjectId is alpha, and Atlas is showing the fallback. The user navigates to Beacon, returns to Atlas, then uses Try again. Which outcome is expected?

Step 7 of 8

Trace Which State Survives Recovery

Trace local, shared, and route-owned state through an error boundary fallback and a successful remount.

Follow component lifetimes

A reset remounts failed content

When ProjectDetails throws, the error boundary replaces its failed descendant subtree with the fallback. The failed child is unmounted; it is not paused for later continuation.

After a successful reset, React mounts a fresh ProjectDetails instance. Its component-local state starts from its initializer again. State owned by components that remain mounted above the boundary retains its current value.

In this lab, draft belongs to ProjectDetails, while sharedNote belongs to WorkspaceProvider and blockedProjectId belongs to ProjectRoute. Ownership and position—not whether a value feels important or shared—determine what survives.

State follows its owner

A comparison of a WorkspaceProvider above an error boundary, where its state remains while ProjectDetails is replaced, and a provider inside the failed subtree, where its state is removed with the subtree.

An owner above the error boundary remains mounted. A provider moved inside the failed subtree would remount and lose the state it owns.

Ownership determines survival

Use the component that owns each value to predict the result of a fallback and successful recovery.

State behavior in the Project Workspace recovery flow
State valueOwnerPositionAfter fallback and successful recovery
draftProjectDetailsInside failed subtreeReturns to its initial empty value
sharedNoteWorkspaceProviderAbove boundaryKeeps its current value
blockedProjectIdProjectRouteAbove boundaryKeeps its current value; explicit repair is needed

Place shared state above the feature boundary

Add the shared state owner

Add a compact context module for the workspace-wide note. This provider owns only sharedNote; keep the local draft in ProjectDetails and keep blockedProjectId in ProjectRoute.

Place WorkspaceProvider above the shell and routes. It will therefore remain mounted when the error boundary replaces project details with its fallback.

Create the workspace context

src/WorkspaceContext.tsx

tsx
import { createContext, useContext, useState } from 'react';
import type { Dispatch, ReactNode, SetStateAction } from 'react';

interface WorkspaceValue {
  sharedNote: string;
  setSharedNote: Dispatch<SetStateAction<string>>;
}

const WorkspaceContext = createContext<WorkspaceValue | null>(null);

interface WorkspaceProviderProps {
  children: ReactNode;
}

export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
  const [sharedNote, setSharedNote] = useState('');

  return (
    <WorkspaceContext.Provider value={{ sharedNote, setSharedNote }}>
      {children}
    </WorkspaceContext.Provider>
  );
}

export function useWorkspace(): WorkspaceValue {
  const workspace = useContext(WorkspaceContext);

  if (workspace === null) {
    throw new Error('useWorkspace must be used within WorkspaceProvider');
  }

  return workspace;
}

Keep the provider above the application

src/main.tsx

tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { App } from './App';
import { WorkspaceProvider } from './WorkspaceContext';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <WorkspaceProvider>
        <App />
      </WorkspaceProvider>
    </BrowserRouter>
  </StrictMode>
);

Observe the state-lifetime experiment

Give each value its intended owner

Replace ProjectDetails.tsx with the version below. It preserves the existing typed failure callback and button while adding the shared input.

All Hooks remain before the conditional throw. The detail heading still becomes the focus destination after a successful remount. The existing ProjectRoute continues to own blockedProjectId and its existing onReset repair clears that value only for an explicit reset.

Add local and shared inputs without removing the trigger

src/features/projects/ProjectDetails.tsx

tsx
import { useEffect, useRef, useState } from 'react';
import { useWorkspace } from '../../WorkspaceContext';

type Project = { id: string; name: string };

interface ProjectDetailsProps {
  project: Project;
  shouldCrash: boolean;
  onSimulateFailure: () => void;
}

export function ProjectDetails({
  project,
  shouldCrash,
  onSimulateFailure
}: ProjectDetailsProps) {
  const [draft, setDraft] = useState('');
  const { sharedNote, setSharedNote } = useWorkspace();
  const headingRef = useRef<HTMLHeadingElement>(null);

  useEffect(() => {
    headingRef.current?.focus();
  }, [project.id]);

  if (shouldCrash) {
    throw new Error('Simulated project render failure');
  }

  return (
    <section aria-labelledby='project-details-heading'>
      <h1
        id='project-details-heading'
        ref={headingRef}
        tabIndex={-1}
      >
        {project.name}
      </h1>
      <label>
        Local draft
        <input
          value={draft}
          onChange={(event) => setDraft(event.currentTarget.value)}
        />
      </label>
      <label>
        Shared note
        <input
          value={sharedNote}
          onChange={(event) => setSharedNote(event.currentTarget.value)}
        />
      </label>
      <button type='button' onClick={onSimulateFailure}>
        Simulate render failure
      </button>
    </section>
  );
}
Expected output
After successful recovery:
Local draft: empty
Shared note: [the value entered before the failure]

Expected rendered state after a successful failure-and-recovery cycle. The shared-note value varies because it is the distinctive value you entered before triggering the failure.

Tip

Run one failure-and-recovery cycle

Run the local app at /projects/alpha. Enter two distinct values: one in Local draft and one in Shared note. Activate Simulate render failure, then activate Try again in the fallback.

After the restored detail heading receives focus, compare both fields. The local field is empty because its ProjectDetails instance was unmounted and remounted. The shared field keeps its value because WorkspaceProvider stayed mounted above the error boundary. The parent-owned blockedProjectId also survives fallback replacement, which is why the explicit reset path must repair it before children retry.

Apply the ownership rule

Trace the values through recovery

In your local application, enter distinct values in Local draft and Shared note, trigger the controlled failure, and recover with Try again. After the detail heading returns, report the observed value in each field. Name the owner of each value and explain why one was reset while the other was preserved. Also explain why blockedProjectId needed parent-owned repair before the retry could succeed.

Write at least 160 characters (0/160).

Step 8 of 8

Verify the Complete Failure and Recovery Flow

Verify a contained, accessible error-boundary recovery flow in the completed local Project Workspace application.

Run the complete verification locally

Prepare two independent verification runs

Verify the completed local Project Workspace in two independent runs. This avoids relying on source edits preserving the boundary state, blockedProjectId, or the workspace provider state.

Run A — unchanged cause: configure the temporary onReset below, then start or reload the app fresh at /projects/alpha. Trigger the failure and activate Try again. Record that the unchanged cause produces the fallback again.

Run B — completed recovery: restore the completed onReset, then start a fresh run. Enter distinct values in Local draft and Shared note before triggering the failure. Use this independent run to check successful recovery, focus, state lifetime, and route-input recovery. Do not edit source while the fallback is displayed.

Compare the verification paths

Use this model while checking the app. The first run isolates what a reset does when nothing repairs the cause. The second run starts with fresh runtime state, then checks the completed recovery design and the meaningful route-input change.

Diagram of two independent Project Workspace verification runs: an unchanged cause returns to fallback, while repaired recovery remounts details and route navigation reaches a healthy project.

A reset alone retries failed children. Explicit repair and a relevant route-input change are distinct recovery paths.

Temporarily verify reset without repair

Before Run A, temporarily replace only the existing onReset prop in src/features/projects/ProjectRoute.tsx with the no-repair version. Save it, then load a fresh app run and perform the check. Afterward, restore the completed version and begin a separate fresh Run B.

Do not make this source edit while depending on a visible fallback or entered field values to remain on screen.

tsx
// Temporary Run A version: leave blockedProjectId unchanged.
onReset={() => {}}

// Restore before starting fresh Run B.
onReset={({ reason }) => {
  if (reason === 'imperative-api') {
    setBlockedProjectId(null);
  }
}}
Expected output
After activating Try again with blockedProjectId still set to 'alpha':
ProjectDetails throws again.
The fallback remains visible or returns:
Project unavailable
This project cannot be displayed right now.

Expected browser observation for Run A after activating Try again; development diagnostics may also appear.

Integrated local verification checklist

Follow the applicable stages in order and record observations from the browser UI, not only from development diagnostics.

Run A proves that reset does not repair an unchanged cause. Run B verifies explicit repair, state lifetime, and route-input recovery without depending on Fast Refresh.
RunStageActionInspect
A1Use the temporary no-repair prop and start fresh at Atlas.Trigger the controlled failure.
A2Activate Try again.Fallback remains or returns because the cause is unchanged.
B1Restore completed onReset and start a fresh run.Enter distinct values in both text controls on Atlas.
B2Trigger failure using the keyboard.Fallback heading receives focus; shell navigation remains usable.
B3Tab through fallback actions, then activate Try again.Detail returns and its heading receives focus.
B4Compare both text controls after recovery.Local draft and Shared note reveal their state lifetimes.
B5Trigger Atlas failure again, then choose Beacon.Beacon renders after the relevant route input changes.
B6Return to Atlas.Atlas can fail again because route change did not repair its stored cause.

Keep the boundary's job narrow

Choose containment or explicit handling

Match each situation to the handling location that keeps the boundary’s responsibility narrow.

Tap an item and then its matching pair.

Apply and review the complete pattern

Apply the pattern to another feature

Transfer the pattern only to a routed feature that can fail independently while the surrounding application remains useful. Place its boundary around the replaceable feature area—not around persistent navigation or the entire shell. Its fallback needs a clear unavailable-state message, a meaningful recovery action when one exists, and a route away.

Before choosing the scope, identify state owners. Keep state that must survive feature replacement above the boundary, such as shell-level controls or deliberately shared workspace state. State owned within failed content is discarded when that content remounts.

Summary

Closing model

A reliable error-boundary flow is a coordinated design, not just a wrapper component.

  • An error boundary contains unexpected descendant rendering failures while the shell and unaffected routes stay available.
  • A fallback needs clear content, keyboard-reachable actions, and deliberate focus after failed content disappears.
  • A reset requests another render; it succeeds only after the cause is repaired or a relevant input changes.
  • Stable primitive resetKeys can reset an existing boundary for a meaningful route-parameter change.
  • Recovery remounts the child subtree: local child state resets, while state owned above the boundary survives.
  • Event-handler failures, rejected requests, validation, empty results, and not-found states need explicit handling instead of a render crash.

Report and complete

Final integration field report

Complete the independent local checks, then submit a short labeled field report.

  1. Containment: What remained usable, and what replaced the failed project content?
  2. Keyboard and focus: Where did focus land after failure? Which two fallback actions did Tab reach? Where did focus land after successful retry?
  3. Recovery comparison: What happened in Run A with the unchanged cause? In Run B after explicit repair and retry? After changing from failed Atlas to Beacon?
  4. State lifetime: What happened to your Local draft and Shared note after the successful remount? Name each value’s owner.
  5. Transfer: Name another independently recoverable routed feature. Where would you place its boundary, and name one state owner that must remain outside it.

Write at least 450 characters (0/450).

Tutorial completed

Congratulations! You completed: Contain React Rendering Failures with Error Boundaries

You completed the failure and recovery flow. You can now scope an error boundary, make its fallback usable, and reason about recovery and state lifetimes.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate