Skip to content
academia.sh

Lesson 12 / 25

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.

Contents

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.

// 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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close