The tea shop wants small pop-up messages, toasts, like Added Sencha to your cart or Order saved. Any component, however deep, should be able to show one without passing callbacks down the tree. Build a toast system on context.
Export ToastProvider by name. It takes children, renders them, and after them renders the toasts that are showing, in the order they were shown (newest last). Each toast is an element with role="status" containing the message in a <p> and a Dismiss button.
<div role="status">
<p>Added Sencha to your cart</p>
<button>Dismiss</button>
</div>
Export useToast() by name. Inside a ToastProvider it returns { show }. show(message, options) adds a toast. options is optional: { duration } is how many milliseconds the toast stays, 3000 when it isn't given. Outside any provider, useToast() throws an Error with the message useToast must be used inside a ToastProvider.
Each toast closes itself duration ms after it was shown, on its own clock: showing, closing, or timing out other toasts never restarts or shortens it. Dismiss closes that toast at once, and only that one. Two toasts with the same message are two separate toasts.
Leave no timers behind: a toast that's been dismissed has no timer waiting, and when the provider leaves the page, nothing is left waiting at all.
show is the same function on every render, so a component can call it from an effect that lists show as a dependency, and show its toast once:
function Welcome() {
const { show } = useToast();
useEffect(() => {
show("Welcome back");
}, [show]);
return null;
}
The default export, App, is a small demo for the Preview. The tests use their own components.
function Shop() {
const { show } = useToast();
return <button onClick={() => show("Added Sencha to your cart")}>Add Sencha</button>;
}
render(
<ToastProvider>
<Shop />
</ToastProvider>,
);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Add Sencha" }));
const toast = screen.getByRole("status");
expect(within(toast).getByText("Added Sencha to your cart")).toBeInTheDocument();
expect(within(toast).getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
function Shop() {
const { show } = useToast();
return (
<div>
<button onClick={() => show("First")}>One</button>
<button onClick={() => show("Second")}>Two</button>
<button onClick={() => show("Third")}>Three</button>
</div>
);
}
render(
<ToastProvider>
<Shop />
</ToastProvider>,
);
await user.click(screen.getByRole("button", { name: "One" }));
await user.click(screen.getByRole("button", { name: "Two" }));
await user.click(screen.getByRole("button", { name: "Three" }));
const toasts = screen.getAllByRole("status");
expect(toasts).toHaveLength(3);
expect(toasts[0]).toHaveTextContent("First");
expect(toasts[1]).toHaveTextContent("Second");
expect(toasts[2]).toHaveTextContent("Third");
const clock = useFakeTimers();
function Shop() {
const { show } = useToast();
return <button onClick={() => show("Order saved")}>Save</button>;
}
render(
<ToastProvider>
<Shop />
</ToastProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await clock.advance(2999);
expect(screen.getByRole("status")).toHaveTextContent("Order saved");
await clock.advance(1);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
const clock = useFakeTimers();
function Shop() {
const { show } = useToast();
return <button onClick={() => show("Order saved", { duration: 5000 })}>Save</button>;
}
render(
<ToastProvider>
<Shop />
</ToastProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await clock.advance(4999);
expect(screen.getByRole("status")).toHaveTextContent("Order saved");
await clock.advance(1);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
const clock = useFakeTimers();
function Shop() {
const { show } = useToast();
return (
<div>
<button onClick={() => show("First")}>One</button>
<button onClick={() => show("Second")}>Two</button>
</div>
);
}
render(
<ToastProvider>
<Shop />
</ToastProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "One" }));
await clock.advance(2000);
fireEvent.click(screen.getByRole("button", { name: "Two" }));
await clock.advance(1000);
expect(screen.getAllByRole("status")).toHaveLength(1);
expect(screen.getByRole("status")).toHaveTextContent("Second");
await clock.advance(1999);
expect(screen.getByRole("status")).toHaveTextContent("Second");
await clock.advance(1);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
const clock = useFakeTimers();
function Shop() {
const { show } = useToast();
return (
<div>
<button onClick={() => show("First")}>One</button>
<button onClick={() => show("Second")}>Two</button>
</div>
);
}
render(
<ToastProvider>
<Shop />
</ToastProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "One" }));
fireEvent.click(screen.getByRole("button", { name: "Two" }));
await flush();
const [first] = screen.getAllByRole("status");
fireEvent.click(within(first).getByRole("button", { name: "Dismiss" }));
await flush();
expect(screen.getAllByRole("status")).toHaveLength(1);
expect(screen.getByRole("status")).toHaveTextContent("Second");
expect(clock.pending()).toBe(1);
fireEvent.click(within(screen.getByRole("status")).getByRole("button", { name: "Dismiss" }));
await flush();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(clock.pending()).toBe(0);
function Shop() {
const { show } = useToast();
return <button onClick={() => show("Added Sencha to your cart")}>Add Sencha</button>;
}
render(
<ToastProvider>
<Shop />
</ToastProvider>,
);
await user.click(screen.getByRole("button", { name: "Add Sencha" }));
await user.click(screen.getByRole("button", { name: "Add Sencha" }));
expect(screen.getAllByRole("status")).toHaveLength(2);
await user.click(within(screen.getAllByRole("status")[0]).getByRole("button", { name: "Dismiss" }));
expect(screen.getAllByRole("status")).toHaveLength(1);
function Welcome() {
const { show } = useToast();
React.useEffect(() => {
show("Welcome back");
}, [show]);
return null;
}
function Shop() {
const { show } = useToast();
return <button onClick={() => show("Added Sencha to your cart")}>Add Sencha</button>;
}
render(
<ToastProvider>
<Welcome />
<Shop />
</ToastProvider>,
);
await flush();
await user.click(screen.getByRole("button", { name: "Add Sencha" }));
const toasts = screen.getAllByRole("status");
expect(toasts).toHaveLength(2);
expect(toasts[0]).toHaveTextContent("Welcome back");
expect(toasts[1]).toHaveTextContent("Added Sencha to your cart");
const clock = useFakeTimers();
function Shop() {
const { show } = useToast();
return (
<div>
<button onClick={() => show("First")}>One</button>
<button onClick={() => show("Second", { duration: 10000 })}>Two</button>
</div>
);
}
const { unmount } = render(
<ToastProvider>
<Shop />
</ToastProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "One" }));
fireEvent.click(screen.getByRole("button", { name: "Two" }));
await flush();
unmount();
expect(clock.pending()).toBe(0);
function Lost() {
const { show } = useToast();
return <button onClick={() => show("Hi")}>Hi</button>;
}
expect(() => render(<Lost />)).toThrow("useToast must be used inside a ToastProvider");
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 Toast notifications. 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.