---
title: 'Error Boundaries'
source: 'https://academia.sh/en/courses/component-based-development/error-boundaries'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:03+00:00'
license: 'CC BY-SA 4.0'
---

# Error Boundaries

Isolating an error thrown during rendering within the component tree; why a half-finished tree is not left in place, the nearest boundary catching it, the effect of boundary placement on the number of components remaining on screen, errors that fall outside the call stack being out of scope, and recovery.

The previous lesson established the mechanism that flows a value down through the tree.
There is one more thing that flows in the reverse direction, and it has not been
addressed up to this lesson: errors.

Suppose a row in the measurement table throws an exception during render because a
record from the server has no unit field. In a hand-written interface, this means that
row stays empty. In a declarative tree, the response is harsher: the default behavior is
for the entire tree to be torn down and the screen to go blank. This lesson first
establishes the reasoning behind this behavior, then shows the way to contain the error
within a subtree, and finally the kinds of errors this way does not cover.

## Why a Half-Finished Tree Is Not Left in Place

Reconciliation compares the **entire** tree a render produces against the previous one
and applies the difference in a single commit. When an exception is thrown midway, two
things exist: a partially computed new tree and the old tree still sitting in the
document.

If the runtime were to write the partial tree at this point, it would put on screen a
state whose components' contracts are not known to hold for any of them. The table
header says "temperature measurements" according to the new filter, while the rows still
show humidity values left over from the old filter. On a measurement station page, this
is worse than a blank screen: the user mistakes wrong data for correct data.

How far the error has spread is also unknown. The component that threw may have
registered an effect, written to a box, or set up half of a subscription. Tearing down is
returning to the one known consistent state — nothing.

This is why the default is strict, and deliberately so. Softening it means telling the
runtime in advance which subtree can fail and what to show in its place.

## The Error Boundary

A component that catches render errors in its subtree and puts a predetermined view in
their place is called an **error boundary**. The view it puts in place will be called the
**fallback** in this lesson; a separate name is used so it is not confused with the
fallback content of slots from The Browser and the Web Platform course.

The propagation rule is the same as exception propagation from the Programming
Fundamentals course: the error climbs the call chain and stops at the **nearest**
boundary that catches it.

```js
// error-boundary.mjs — a render error propagating up to the nearest boundary
const tree = (boundaries, broken) => ({
  name: "page", boundary: boundaries.includes("page"), children: [
    { name: "title", children: [] },
    { name: "filter-panel", boundary: boundaries.includes("filter-panel"), children: [
      { name: "filter-list", children: [] },
    ] },
    { name: "measurement-table", boundary: boundaries.includes("measurement-table"), children: [
      { name: "row-1", children: [] },
      { name: "row-2", error: broken ? "missing unit field" : null, children: [] },
      { name: "row-3", children: [] },
    ] },
  ],
});

// The returned count is the number of components remaining on screen: the caught subtree's work is discarded.
function render(n, log) {
  if (n.boundary) {
    try {
      const results = n.children.map((c) => render(c, log));
      return { text: `${n.name}(${results.map((x) => x.text).join(" ")})`,
               count: 1 + results.reduce((t, x) => t + x.count, 0) };
    } catch (err) {
      log.push(`${n.name} caught -> ${err.message}`);
      return { text: `${n.name}(FALLBACK)`, count: 1 };
    }
  }
  if (n.error) throw new Error(`${n.name}: ${n.error}`);
  if (n.children.length === 0) return { text: n.name, count: 1 };
  const results = n.children.map((c) => render(c, log));
  return { text: `${n.name}(${results.map((x) => x.text).join(" ")})`,
           count: 1 + results.reduce((t, x) => t + x.count, 0) };
}

const countNodes = (n) => 1 + n.children.reduce((t, c) => t + countNodes(c), 0);
const TOTAL = countNodes(tree([], false));

const ALL = ["page", "filter-panel", "measurement-table"];
const SCENARIOS = [
  ["three boundaries, broken record", ALL, true],
  ["page boundary only, broken record", ["page"], true],
  ["no boundary, broken record", [], true],
  ["three boundaries, record fixed", ALL, false],
];

for (const [title, boundaries, broken] of SCENARIOS) {
  const log = [];
  console.log(title + ":");
  let result;
  try {
    result = render(tree(boundaries, broken), log);
  } catch (err) {
    log.push(`nobody caught -> ${err.message}`);
    result = { text: "(tree torn down entirely)", count: 0 };
  }
  for (const s of log) console.log(`  ${s}`);
  console.log(`  on screen: ${result.text}`);
  console.log(`  components remaining on screen: ${result.count}/${TOTAL}`);
}
```

```
three boundaries, broken record:
  measurement-table caught -> row-2: missing unit field
  on screen: page(title filter-panel(filter-list) measurement-table(FALLBACK))
  components remaining on screen: 5/8
page boundary only, broken record:
  page caught -> row-2: missing unit field
  on screen: page(FALLBACK)
  components remaining on screen: 1/8
no boundary, broken record:
  nobody caught -> row-2: missing unit field
  on screen: (tree torn down entirely)
  components remaining on screen: 0/8
three boundaries, record fixed:
  on screen: page(title filter-panel(filter-list) measurement-table(row-1 row-2 row-3))
  components remaining on screen: 8/8
```

The same error produces three different results depending on boundary placement.

If the table has its own boundary, only the table falls back; the title and the filter
panel stay up and remain **usable**. The user can change the filter and retry with a
different query.

If there is only a page-level boundary, the error climbs three levels and the entire page
collapses to a single fallback. With no boundary at all, nothing remains on screen.

The last scenario underscores a point: a boundary does nothing when there is no error. It
carries no invisible cost; it only engages at the moment of an error.

## Placing Boundaries

The 5, 1, and 0 in the output give the placement rule: **a boundary is placed directly
above the part that can fail, not at the very top of the page.**

A three-level placement is enough in practice. A last-resort boundary sits at the
outermost edge of the page; even if it catches nothing else, it shows a message instead
of a blank screen. Every main section of the page — the filter panel, the measurement
table, the comparison panel — carries its own boundary; while one collapses, the others
keep working. Small parts that depend on external data and can refresh independently, a
single measurement badge for instance, can carry their own boundary.

The opposite extreme is also a mistake. Wrapping every component with a boundary silently
puts a small fallback in place of the collapsed part, and the page looks "working" while
half of it is missing. A boundary is not a tool for hiding errors; it is a tool for
limiting damage.

## What a Boundary Does Not Catch

A boundary is a call stack mechanism, and its scope is limited by that stack.

```js
// not-caught.mjs — a boundary's scope is limited by the call stack
const stack = [];
const stackState = () => `stack = [${stack.join(" > ") || "empty"}]`;

function boundary(name, body) {
  stack.push(name);
  try {
    return body();
  } catch (err) {
    console.log(`  ${name} CAUGHT -> ${err.message}`);
    return `${name}(FALLBACK)`;
  } finally {
    stack.pop();
  }
}

const pending = [];
let handler = null;

console.log("A. an error thrown during render");
const output = boundary("table-boundary", () => {
  console.log(`  body running, ${stackState()}`);
  handler = () => { throw new Error("delete button handler blew up"); };
  pending.push(["measurement response", () => { throw new Error("network response corrupted") }]);
  throw new Error("row-2: missing unit field");
});
console.log(`  on screen: ${output}`);

console.log("B. event handler: the render is long since finished");
console.log(`  ${stackState()}`);
try { handler(); } catch (err) { console.log(`  did not hit the boundary -> ${err.message}`); }

console.log("C. asynchronous callback: a separate round");
for (const [name, job] of pending) {
  console.log(`  ${name} running, ${stackState()}`);
  try { job(); } catch (err) { console.log(`  did not hit the boundary -> ${err.message}`); }
}
```

```
A. an error thrown during render
  body running, stack = [table-boundary]
  table-boundary CAUGHT -> row-2: missing unit field
  on screen: table-boundary(FALLBACK)
B. event handler: the render is long since finished
  stack = [empty]
  did not hit the boundary -> delete button handler blew up
C. asynchronous callback: a separate round
  measurement response running, stack = [empty]
  did not hit the boundary -> network response corrupted
```

The stack lines across the three sections explain the difference on their own. While the
body runs, the boundary is on the stack, and the thrown error hits it. By the time the
event handler is called and the asynchronous callback runs, the stack is empty; the
boundary's protective block has long since ended.

The practical consequence of this is that error handling is set up in two separate
places. Render errors are met by a boundary. Errors in event handlers and asynchronous
work are caught inside the code doing that work, and the result is written to state — an
error message state, a retry button. The same distinction holds for unhandled promise
rejection from the Asynchronous JavaScript and the Runtime course.

There are two more cases a boundary does not cover: if the boundary's own fallback view
throws, the error climbs to the next boundary up, and errors from a render produced
server-side never hit a boundary on the client.

## Recovery and Reporting

Catching is not enough; the way out of a caught error is also designed.

Because the boundary itself sits **above** the component that threw, its own state is
intact. This makes recovery possible. The most common form is a retry action: the
boundary shows a button in the fallback view; the button changes the subtree's identity
and rebuilds it from scratch. The identity changing matters — re-rendering with the same
identity preserves the state that led to the error and produces the same error again.

The second form is redirection: if the detail view collapses because of a corrupted
record, returning to the list view leaves the user on a working screen.

Reporting is done to two separate audiences. The developer receives a record written to a
log: the error message, the stack trace, which component it occurred in, the current
route. The user is shown neither a stack trace nor an error code; a sentence saying what
happened and what can be done is enough. The fallback view also has to be accessible:
announcing the message to a screen reader and moving focus to the retry button follow the
focus management rules from the Web Fundamentals and HTML course.

## Summary

- An error thrown midway through a render leaves a half-finished tree; writing the
  partial tree produces an inconsistent screen, which is why the default behavior is to
  tear down the tree.
- An error boundary is a component that catches render errors in its subtree and puts a
  fallback view in their place; the error stops at the nearest boundary.
- Boundary placement determines how many components remain on screen: a section-level
  boundary leaves the rest of the page usable, a page-level-only boundary does not.
- A boundary is a call stack mechanism; errors thrown in event handlers, timers, and
  asynchronous callbacks fall outside its scope and are caught there instead.
- Recovery means rebuilding the subtree by changing its identity; re-rendering with the
  same identity produces the same error.
- The log gets the detail, the user gets one sentence and one action; the fallback view
  must also be accessible.

## Next Step

An error boundary separates a subtree's **failure** from the rest of the tree. There is a
second state that needs the same kind of separation: a subtree that is not yet **ready**.
The measurement table cannot render until data arrives from the server; the comparison
panel waits for the second station's records. Managing this with a separate "loading"
flag in every component produces state combinations that can contradict each other, and
leads every level of a nested wait to spin its own indicator. The next lesson takes up
how the same upward-signaling mechanism as the error boundary is used for waiting, and
where loading views get collected.
