Skip to the code
cf.

Split a context to save renders

The tea shop's menu has dozens of Add buttons, all reading a cart context. The cart works, but every time anything changes, every button renders again, even though a button only ever calls add and never shows the cart. The starter is that working but wasteful code. Change it so the work matches what each component uses.

Keep the exports and what they do. CartProvider takes children and holds the cart, a list of { id, name, price, quantity } lines, starting empty. useCart() returns the lines. useCartActions() returns { add, remove, clear }: add(tea) adds one of a tea ({ id, name, price }), remove(id) takes out its line, and clear() empties the cart.

The three components stay as they are, and each calls its optional onRender prop every time it renders, which is how the tests count renders:

<CartBadge onRender={...} />            // <p>Cart: 3</p>, from useCart()
<AddButton tea={tea} onRender={...} />  // "Add Sencha", calls add(tea) from useCartActions()
<ClearButton onRender={...} />          // "Empty cart", calls clear() from useCartActions()

What to fix: a component that only calls useCartActions() never renders again when the cart changes. A component that calls useCart() renders once per change, no more. When the component above CartProvider renders again but the cart hasn't changed, no consumer renders again. And useCartActions() returns the same object on every render.

The tests render without Strict Mode, so each update means exactly one render. Don't wrap the components in memo, and don't change them: the fix belongs in the provider and the hooks. The default export, App, is a small menu for the Preview that logs each render to the console.

What the tests check

  1. the badge counts what the Add buttons add
    const sencha = { id: "sencha", name: "Sencha", price: 4 };
    const assam = { id: "assam", name: "Assam", price: 3 };
    render(
      <CartProvider>
        <CartBadge />
        <AddButton tea={sencha} />
        <AddButton tea={assam} />
      </CartProvider>,
    );
    expect(screen.getByText("Cart: 0")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Add Assam" }));
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    expect(screen.getByText("Cart: 3")).toBeInTheDocument();
    
  2. Empty cart empties the cart
    const sencha = { id: "sencha", name: "Sencha", price: 4 };
    render(
      <CartProvider>
        <CartBadge />
        <AddButton tea={sencha} />
        <ClearButton />
      </CartProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Empty cart" }));
    expect(screen.getByText("Cart: 0")).toBeInTheDocument();
    
  3. an Add button doesn't render again when the cart changes
    const sencha = { id: "sencha", name: "Sencha", price: 4 };
    const assam = { id: "assam", name: "Assam", price: 3 };
    let senchaRenders = 0;
    let assamRenders = 0;
    render(
      <CartProvider>
        <CartBadge />
        <AddButton tea={sencha} onRender={() => senchaRenders++} />
        <AddButton tea={assam} onRender={() => assamRenders++} />
      </CartProvider>,
    );
    expect(senchaRenders).toBe(1);
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Add Assam" }));
    expect(screen.getByText("Cart: 3")).toBeInTheDocument();
    expect(senchaRenders).toBe(1);
    expect(assamRenders).toBe(1);
    
  4. the Empty cart button doesn't render again when the cart changes
    const sencha = { id: "sencha", name: "Sencha", price: 4 };
    let clearRenders = 0;
    render(
      <CartProvider>
        <CartBadge />
        <AddButton tea={sencha} />
        <ClearButton onRender={() => clearRenders++} />
      </CartProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Empty cart" }));
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    expect(screen.getByText("Cart: 1")).toBeInTheDocument();
    expect(clearRenders).toBe(1);
    
  5. the badge renders once per change
    const sencha = { id: "sencha", name: "Sencha", price: 4 };
    let badgeRenders = 0;
    render(
      <CartProvider>
        <CartBadge onRender={() => badgeRenders++} />
        <AddButton tea={sencha} />
        <ClearButton />
      </CartProvider>,
    );
    expect(badgeRenders).toBe(1);
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    expect(screen.getByText("Cart: 1")).toBeInTheDocument();
    expect(badgeRenders).toBe(2);
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Empty cart" }));
    expect(screen.getByText("Cart: 0")).toBeInTheDocument();
    expect(badgeRenders).toBe(4);
    
  6. a render above the provider doesn't re-render consumers when the cart is the same
    const sencha = { id: "sencha", name: "Sencha", price: 4 };
    let badgeRenders = 0;
    let addRenders = 0;
    function Shell({ children }) {
      const [refreshes, setRefreshes] = React.useState(0);
      return (
        <div>
          <button onClick={() => setRefreshes(refreshes + 1)}>Refresh ({refreshes})</button>
          <CartProvider>{children}</CartProvider>
        </div>
      );
    }
    render(
      <Shell>
        <CartBadge onRender={() => badgeRenders++} />
        <AddButton tea={sencha} onRender={() => addRenders++} />
      </Shell>,
    );
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    expect(badgeRenders).toBe(2);
    await user.click(screen.getByRole("button", { name: "Refresh (0)" }));
    await user.click(screen.getByRole("button", { name: "Refresh (1)" }));
    expect(screen.getByRole("button", { name: "Refresh (2)" })).toBeInTheDocument();
    expect(screen.getByText("Cart: 1")).toBeInTheDocument();
    expect(badgeRenders).toBe(2);
    expect(addRenders).toBe(1);
    
  7. useCartActions returns the same object on every render
    const sencha = { id: "sencha", name: "Sencha", price: 4 };
    const seen = [];
    function Probe() {
      const lines = useCart();
      const actions = useCartActions();
      seen.push(actions);
      return <p>{lines.length} lines</p>;
    }
    render(
      <CartProvider>
        <Probe />
        <AddButton tea={sencha} />
        <ClearButton />
      </CartProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Empty cart" }));
    expect(seen.length).toBe(3);
    expect(seen[1]).toBe(seen[0]);
    expect(seen[2]).toBe(seen[0]);
    
  8. useCart gives the lines, and remove takes one out
    function Lines() {
      const lines = useCart();
      const { add, remove } = useCartActions();
      return (
        <div>
          <p>{lines.map((line) => line.name + ":" + line.quantity).join(",") || "empty"}</p>
          <button onClick={() => add({ id: "chai", name: "Chai", price: 2.5 })}>Add chai</button>
          <button onClick={() => add({ id: "mint", name: "Mint", price: 1 })}>Add mint</button>
          <button onClick={() => remove("chai")}>Remove chai</button>
        </div>
      );
    }
    render(
      <CartProvider>
        <Lines />
      </CartProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Add chai" }));
    await user.click(screen.getByRole("button", { name: "Add mint" }));
    await user.click(screen.getByRole("button", { name: "Add chai" }));
    expect(screen.getByText("Chai:2,Mint:1")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Remove chai" }));
    expect(screen.getByText("Mint:1")).toBeInTheDocument();
    

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