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]
render(
<ErrorBoundary>
<p>Tea is ready</p>
</ErrorBoundary>,
);
expect(screen.getByText("Tea is ready")).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
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();
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();
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();
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);
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);
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();
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();
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);
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);
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).
I'm here to help with Error boundary with Try again. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.