Skip to the code
cf.

Tabs that keep their state with Activity

The tea shop's order desk has two tabs: a Note tab with a scratch pad, and a Live orders tab that listens for new orders. Switching tabs used to wipe the note, so build OrderDesk, the default export, with <Activity> to hide the inactive tab instead of removing it.

It takes subscribe(onOrder), which starts listening and returns a function that stops. Each new order is passed to onOrder as { id, item }. The default in the starter is a pretend feed that sends an order every two seconds.

Render two buttons with role="tab", Note and Live orders, inside an element with role="tablist". The selected one has aria-selected="true". Note is selected at first.

The Note tab shows a text area labelled Note. The Live orders tab shows No orders yet. until an order arrives, then a list with one item per order, showing its item, oldest first.

Wrap each tab in <Activity mode>, "visible" for the selected tab and "hidden" for the other. A hidden tab stays out of sight but keeps its state: the typed note and the orders received so far are still there when you come back.

The Live orders tab subscribes in an Effect while it's visible only. It doesn't subscribe before it's first shown, stops (calls the function subscribe returned) when it's hidden, and subscribes again when it's shown again. Orders sent while it's hidden are missed.

<OrderDesk subscribe={subscribeToOrders} />
 
// [Note] [Live orders]
// Note: [ Call the supplier ]
//
// Live orders, after two orders:
// - Sencha
// - Assam

What the tests check

  1. shows the Note tab first
    const listeners = new Set();
    const feed = { stopped: 0, listening: () => listeners.size };
    const subscribe = spy((onOrder) => {
      listeners.add(onOrder);
      return () => {
        listeners.delete(onOrder);
        feed.stopped += 1;
      };
    });
    let lastId = 0;
    const sendOrder = (item) =>
      act(async () => {
        lastId += 1;
        for (const onOrder of [...listeners]) onOrder({ id: lastId, item });
      });
    render(<App subscribe={subscribe} />);
    expect(screen.getByRole("tab", { name: "Note" })).toHaveAttribute("aria-selected", "true");
    expect(screen.getByRole("tab", { name: "Live orders" })).toHaveAttribute("aria-selected", "false");
    expect(screen.getByLabelText("Note")).toBeVisible();
    await flush();
    expect(screen.getByText("No orders yet.")).not.toBeVisible();
    
  2. doesn't subscribe before Live orders is shown
    const listeners = new Set();
    const feed = { stopped: 0, listening: () => listeners.size };
    const subscribe = spy((onOrder) => {
      listeners.add(onOrder);
      return () => {
        listeners.delete(onOrder);
        feed.stopped += 1;
      };
    });
    let lastId = 0;
    const sendOrder = (item) =>
      act(async () => {
        lastId += 1;
        for (const onOrder of [...listeners]) onOrder({ id: lastId, item });
      });
    render(<App subscribe={subscribe} />);
    await flush();
    expect(subscribe).toHaveBeenCalledTimes(0);
    
  3. shows orders as they arrive while Live orders is shown
    const listeners = new Set();
    const feed = { stopped: 0, listening: () => listeners.size };
    const subscribe = spy((onOrder) => {
      listeners.add(onOrder);
      return () => {
        listeners.delete(onOrder);
        feed.stopped += 1;
      };
    });
    let lastId = 0;
    const sendOrder = (item) =>
      act(async () => {
        lastId += 1;
        for (const onOrder of [...listeners]) onOrder({ id: lastId, item });
      });
    render(<App subscribe={subscribe} />);
    await user.click(screen.getByRole("tab", { name: "Live orders" }));
    expect(subscribe).toHaveBeenCalledTimes(1);
    expect(screen.getByText("No orders yet.")).toBeVisible();
    expect(screen.getByLabelText("Note")).not.toBeVisible();
    await sendOrder("Sencha");
    await sendOrder("Assam");
    expect(screen.getAllByRole("listitem").map((item) => item.textContent)).toEqual(["Sencha", "Assam"]);
    
  4. keeps the typed note while another tab is shown
    const listeners = new Set();
    const feed = { stopped: 0, listening: () => listeners.size };
    const subscribe = spy((onOrder) => {
      listeners.add(onOrder);
      return () => {
        listeners.delete(onOrder);
        feed.stopped += 1;
      };
    });
    let lastId = 0;
    const sendOrder = (item) =>
      act(async () => {
        lastId += 1;
        for (const onOrder of [...listeners]) onOrder({ id: lastId, item });
      });
    render(<App subscribe={subscribe} />);
    await user.type(screen.getByLabelText("Note"), "Call the supplier");
    await user.click(screen.getByRole("tab", { name: "Live orders" }));
    expect(screen.getByLabelText("Note")).not.toBeVisible();
    await user.click(screen.getByRole("tab", { name: "Note" }));
    expect(screen.getByLabelText("Note")).toBeVisible();
    expect(screen.getByLabelText("Note")).toHaveValue("Call the supplier");
    
  5. stops listening when Live orders is hidden
    const listeners = new Set();
    const feed = { stopped: 0, listening: () => listeners.size };
    const subscribe = spy((onOrder) => {
      listeners.add(onOrder);
      return () => {
        listeners.delete(onOrder);
        feed.stopped += 1;
      };
    });
    let lastId = 0;
    const sendOrder = (item) =>
      act(async () => {
        lastId += 1;
        for (const onOrder of [...listeners]) onOrder({ id: lastId, item });
      });
    render(<App subscribe={subscribe} />);
    await user.click(screen.getByRole("tab", { name: "Live orders" }));
    expect(feed.listening()).toBe(1);
    await user.click(screen.getByRole("tab", { name: "Note" }));
    expect(feed.stopped).toBe(1);
    expect(feed.listening()).toBe(0);
    
  6. keeps the orders it got and listens again when shown again
    const listeners = new Set();
    const feed = { stopped: 0, listening: () => listeners.size };
    const subscribe = spy((onOrder) => {
      listeners.add(onOrder);
      return () => {
        listeners.delete(onOrder);
        feed.stopped += 1;
      };
    });
    let lastId = 0;
    const sendOrder = (item) =>
      act(async () => {
        lastId += 1;
        for (const onOrder of [...listeners]) onOrder({ id: lastId, item });
      });
    render(<App subscribe={subscribe} />);
    await user.click(screen.getByRole("tab", { name: "Live orders" }));
    await sendOrder("Sencha");
    await user.click(screen.getByRole("tab", { name: "Note" }));
    await sendOrder("Assam");
    await user.click(screen.getByRole("tab", { name: "Live orders" }));
    expect(subscribe).toHaveBeenCalledTimes(2);
    expect(screen.getAllByRole("listitem").map((item) => item.textContent)).toEqual(["Sencha"]);
    await sendOrder("Rooibos");
    expect(screen.getAllByRole("listitem").map((item) => item.textContent)).toEqual(["Sencha", "Rooibos"]);
    

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