Skip to the code
cf.

Toast notifications

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.

What the tests check

  1. show adds a toast with role status, the message, and a Dismiss button
    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();
    
  2. toasts stack in the order they were shown
    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");
    
  3. a toast closes itself after 3000 ms by default
    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();
    
  4. a toast stays for its own duration
    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();
    
  5. each toast keeps its own clock when more toasts arrive
    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();
    
  6. Dismiss closes only its own toast and clears its timer
    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);
    
  7. two toasts with the same message are two toasts
    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);
    
  8. show is the same function on every render
    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");
    
  9. leaves no timers behind when the provider leaves the page
    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);
    
  10. useToast outside a ToastProvider throws a helpful error
    function Lost() {
      const { show } = useToast();
      return <button onClick={() => show("Hi")}>Hi</button>;
    }
    expect(() => render(<Lost />)).toThrow("useToast must be used inside a ToastProvider");
    

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