Skip to the code
cf.

Stable callbacks for memo children

The tea shop's order page has cup buttons and a note box, and both child components are already wrapped in memo. Yet typing a note renders the cup buttons again on every keystroke, and adding a cup renders the note box again. The cause is in the parent: it hands its children a new function on every render, so memo always sees a changed prop. The starter is that code. Fix TeaOrder so each child renders only when something it shows has changed.

Keep what it does. TeaOrder takes price, the price of one cup (4 by default), and an optional onRender. It shows Cups: 0 and Total: $0.00 at first. CupControls has two buttons, Add a cup and Remove a cup, which change the count by one; the count never goes below 0. NoteField is a text box labelled Note for the barista.

CupControls and NoteField stay as they are. Each calls onRender with its own name ("CupControls" or "NoteField") every time it renders, which is how the tests count renders. Don't remove their memo and don't change them: the fix belongs in TeaOrder.

What to fix: typing a note never renders CupControls again, and renders NoteField once per keystroke (it shows the note). Adding or removing cups renders neither child again: the count and total are shown by TeaOrder itself. Rendering TeaOrder again with the same props renders neither child.

The tests render without Strict Mode, so each update means exactly one render. Export TeaOrder by name. The default export, App, renders it and logs each child render for the Preview.

<TeaOrder price={4} onRender={(name) => console.log(name)} />
 
// after adding two cups:
<p>Cups: 2</p>
<p>Total: $8.00</p>
<button>Add a cup</button> <button>Remove a cup</button>
<label>Note for the barista <input value="" /></label>

What the tests check

  1. adds and removes cups and shows the total
    render(<TeaOrder />);
    expect(screen.getByText("Cups: 0")).toBeInTheDocument();
    expect(screen.getByText("Total: $0.00")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Add a cup" }));
    await user.click(screen.getByRole("button", { name: "Add a cup" }));
    expect(screen.getByText("Cups: 2")).toBeInTheDocument();
    expect(screen.getByText("Total: $8.00")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Remove a cup" }));
    expect(screen.getByText("Cups: 1")).toBeInTheDocument();
    expect(screen.getByText("Total: $4.00")).toBeInTheDocument();
    
  2. never goes below 0 cups
    render(<TeaOrder />);
    await user.click(screen.getByRole("button", { name: "Add a cup" }));
    await user.click(screen.getByRole("button", { name: "Remove a cup" }));
    await user.click(screen.getByRole("button", { name: "Remove a cup" }));
    expect(screen.getByText("Cups: 0")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Add a cup" }));
    expect(screen.getByText("Cups: 1")).toBeInTheDocument();
    
  3. uses the price prop for the total
    render(<TeaOrder price={3.5} />);
    for (let i = 0; i < 3; i++) await user.click(screen.getByRole("button", { name: "Add a cup" }));
    expect(screen.getByText("Total: $10.50")).toBeInTheDocument();
    
  4. the note box shows what you type, rendering once per keystroke
    const renders = { CupControls: 0, NoteField: 0 };
    render(<TeaOrder onRender={(name) => renders[name]++} />);
    expect(renders).toEqual({ CupControls: 1, NoteField: 1 });
    await user.type(screen.getByLabelText("Note for the barista"), "oat");
    expect(screen.getByLabelText("Note for the barista")).toHaveValue("oat");
    expect(renders.NoteField).toBe(4);
    
  5. typing a note doesn't render CupControls again
    const renders = { CupControls: 0, NoteField: 0 };
    render(<TeaOrder onRender={(name) => renders[name]++} />);
    await user.type(screen.getByLabelText("Note for the barista"), "no sugar");
    expect(renders.CupControls).toBe(1);
    
  6. adding cups renders neither child again
    const renders = { CupControls: 0, NoteField: 0 };
    render(<TeaOrder onRender={(name) => renders[name]++} />);
    for (let i = 0; i < 3; i++) await user.click(screen.getByRole("button", { name: "Add a cup" }));
    expect(screen.getByText("Cups: 3")).toBeInTheDocument();
    expect(renders).toEqual({ CupControls: 1, NoteField: 1 });
    
  7. removing cups renders neither child again
    const renders = { CupControls: 0, NoteField: 0 };
    render(<TeaOrder onRender={(name) => renders[name]++} />);
    await user.click(screen.getByRole("button", { name: "Add a cup" }));
    await user.click(screen.getByRole("button", { name: "Add a cup" }));
    await user.click(screen.getByRole("button", { name: "Remove a cup" }));
    expect(screen.getByText("Cups: 1")).toBeInTheDocument();
    expect(renders).toEqual({ CupControls: 1, NoteField: 1 });
    
  8. rendering TeaOrder again with the same props renders neither child
    const renders = { CupControls: 0, NoteField: 0 };
    const onRender = (name) => renders[name]++;
    const { rerender } = render(<TeaOrder price={4} onRender={onRender} />);
    await user.click(screen.getByRole("button", { name: "Add a cup" }));
    rerender(<TeaOrder price={4} onRender={onRender} />);
    expect(screen.getByText("Cups: 1")).toBeInTheDocument();
    expect(renders).toEqual({ CupControls: 1, NoteField: 1 });
    

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