Skip to the code
cf.

Error boundary with Try again

On the tea shop's menu, one broken tea card shouldn't blank the whole page. Build ErrorBoundary, a named export and a class component, that catches errors thrown while its children render and shows a fallback in their place.

The fallback is an element with role="alert" holding Something went wrong., the caught error's message, and a Try again button. Try again clears the error and renders the children again. If they throw again, the fallback comes back.

It takes two optional props. onReset() is called each time the boundary resets. resetKeys is an array: when the boundary is showing the fallback and any value in it differs from the last render (compare item by item with Object.is, and treat a different length as a change), it resets as if Try again were pressed. A new array with the same values is not a change.

When the boundary is not showing the fallback, a change in resetKeys does nothing: no onReset, and the children keep their state. If the very update that changes resetKeys is also the one that makes a child throw, show the fallback and don't reset.

The default export App is a demo, already written: it lets you pick a tea and shows its card, and the Mystery tea's card throws. Its boundary has resetKeys={[tea]}, so picking another tea recovers.

<ErrorBoundary resetKeys={[tea]} onReset={() => console.log("reset")}>
  <TeaCard tea={tea} />
</ErrorBoundary>
 
// When TeaCard throws new Error("No notes for mystery."):
// Something went wrong.
// No notes for mystery.
// [Try again]

What the tests check

  1. renders its children when nothing throws
    render(
      <ErrorBoundary>
        <p>Tea is ready</p>
      </ErrorBoundary>,
    );
    expect(screen.getByText("Tea is ready")).toBeInTheDocument();
    expect(screen.queryByRole("alert")).not.toBeInTheDocument();
    
  2. shows the error's message in an alert when a child throws
    function Broken() {
      throw new Error("Out of milk.");
    }
    render(
      <div>
        <h1>Menu</h1>
        <ErrorBoundary>
          <Broken />
        </ErrorBoundary>
      </div>,
    );
    const alert = screen.getByRole("alert");
    expect(alert).toHaveTextContent("Something went wrong.");
    expect(alert).toHaveTextContent("Out of milk.");
    expect(within(alert).getByRole("button", { name: "Try again" })).toBeInTheDocument();
    expect(screen.getByRole("heading", { name: "Menu" })).toBeInTheDocument();
    
  3. Try again renders the children again
    let broken = true;
    function Kettle() {
      if (broken) throw new Error("The kettle is off.");
      return <p>Tea is ready</p>;
    }
    render(
      <ErrorBoundary>
        <Kettle />
      </ErrorBoundary>,
    );
    expect(screen.getByRole("alert")).toHaveTextContent("The kettle is off.");
    broken = false;
    await user.click(screen.getByRole("button", { name: "Try again" }));
    expect(screen.getByText("Tea is ready")).toBeInTheDocument();
    expect(screen.queryByRole("alert")).not.toBeInTheDocument();
    
  4. shows the fallback again when the children throw again
    let message = "The kettle is off.";
    function Kettle() {
      throw new Error(message);
    }
    render(
      <ErrorBoundary>
        <Kettle />
      </ErrorBoundary>,
    );
    message = "Still no water.";
    await user.click(screen.getByRole("button", { name: "Try again" }));
    expect(screen.getByRole("alert")).toHaveTextContent("Still no water.");
    expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument();
    
  5. calls onReset when Try again is pressed
    let broken = true;
    function Kettle() {
      if (broken) throw new Error("The kettle is off.");
      return <p>Tea is ready</p>;
    }
    const onReset = spy();
    render(
      <ErrorBoundary onReset={onReset}>
        <Kettle />
      </ErrorBoundary>,
    );
    expect(onReset).toHaveBeenCalledTimes(0);
    broken = false;
    await user.click(screen.getByRole("button", { name: "Try again" }));
    expect(onReset).toHaveBeenCalledTimes(1);
    
  6. resets when a value in resetKeys changes
    function Price({ tea }) {
      if (tea === "mystery") throw new Error("No price for mystery.");
      return <p>{tea} costs 4</p>;
    }
    const onReset = spy();
    const { rerender } = render(
      <ErrorBoundary resetKeys={["mystery"]} onReset={onReset}>
        <Price tea="mystery" />
      </ErrorBoundary>,
    );
    expect(screen.getByRole("alert")).toHaveTextContent("No price for mystery.");
    rerender(
      <ErrorBoundary resetKeys={["sencha"]} onReset={onReset}>
        <Price tea="sencha" />
      </ErrorBoundary>,
    );
    expect(screen.getByText("sencha costs 4")).toBeInTheDocument();
    expect(screen.queryByRole("alert")).not.toBeInTheDocument();
    expect(onReset).toHaveBeenCalledTimes(1);
    
  7. a new resetKeys array with the same values doesn't reset
    let broken = true;
    function Kettle() {
      if (broken) throw new Error("The kettle is off.");
      return <p>Tea is ready</p>;
    }
    const { rerender } = render(
      <ErrorBoundary resetKeys={[1, "a"]}>
        <Kettle />
      </ErrorBoundary>,
    );
    broken = false;
    rerender(
      <ErrorBoundary resetKeys={[1, "a"]}>
        <Kettle />
      </ErrorBoundary>,
    );
    expect(screen.getByRole("alert")).toHaveTextContent("The kettle is off.");
    expect(screen.queryByText("Tea is ready")).not.toBeInTheDocument();
    
  8. a longer resetKeys array counts as a change
    let broken = true;
    function Kettle() {
      if (broken) throw new Error("The kettle is off.");
      return <p>Tea is ready</p>;
    }
    const { rerender } = render(
      <ErrorBoundary resetKeys={[1]}>
        <Kettle />
      </ErrorBoundary>,
    );
    broken = false;
    rerender(
      <ErrorBoundary resetKeys={[1, 2]}>
        <Kettle />
      </ErrorBoundary>,
    );
    expect(screen.getByText("Tea is ready")).toBeInTheDocument();
    
  9. a resetKeys change while nothing is wrong does nothing
    const onReset = spy();
    const { rerender } = render(
      <ErrorBoundary resetKeys={["sencha"]} onReset={onReset}>
        <input aria-label="Note" />
      </ErrorBoundary>,
    );
    await user.type(screen.getByLabelText("Note"), "Lovely");
    rerender(
      <ErrorBoundary resetKeys={["assam"]} onReset={onReset}>
        <input aria-label="Note" />
      </ErrorBoundary>,
    );
    expect(screen.getByLabelText("Note")).toHaveValue("Lovely");
    expect(onReset).toHaveBeenCalledTimes(0);
    
  10. doesn't reset when the update that changes resetKeys is the one that throws
    function Price({ tea }) {
      if (tea === "mystery") throw new Error("No price for mystery.");
      return <p>{tea} costs 4</p>;
    }
    const onReset = spy();
    const { rerender } = render(
      <ErrorBoundary resetKeys={["sencha"]} onReset={onReset}>
        <Price tea="sencha" />
      </ErrorBoundary>,
    );
    rerender(
      <ErrorBoundary resetKeys={["mystery"]} onReset={onReset}>
        <Price tea="mystery" />
      </ErrorBoundary>,
    );
    expect(screen.getByRole("alert")).toHaveTextContent("No price for mystery.");
    expect(onReset).toHaveBeenCalledTimes(0);
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).