4 updater form escape
GOAL
predict what two value-form setSpent calls in one click really commit, then escape the trap with the updater form.
CONCEPT
each redraw's `spent` is a const - a slot whose value never changes. Both setSpent calls in one handler read that same captured slot, and React batches them, so there is no redraw in between for the second call to read.
HINT
setSpent also accepts a FUNCTION. React calls that function at apply time and hands it the latest pending value, so chained updates stack instead of overwriting one another.
MIRRORS
the HUD's skill tree, whose branch toggle captures a node id but never an open flag. It asks the tree store for that branch's state at the moment of the click and flips what it finds there - the same read-the-latest-rather-than-the- captured move the updater form makes.
Run
This koan renders React, so it needs a one-time setup.
pnpm koan 09-closures-and-stores/04-updater-form-escape.tsxSource
// DOJO · Module 9 / Exercise 4 — updater form escape
// GOAL: predict what two value-form setSpent calls in one click really
// commit, then escape the trap with the updater form.
// CONCEPT: each redraw's `spent` is a const - a slot whose value never
// changes. Both setSpent calls in one handler read that same
// captured slot, and React batches them, so there is no redraw
// in between for the second call to read.
// HINT: setSpent also accepts a FUNCTION. React calls that function at
// apply time and hands it the latest pending value, so chained
// updates stack instead of overwriting one another.
// MIRRORS: the HUD's skill tree, whose branch toggle captures a node
// id but never an open flag. It asks the tree store for that
// branch's state at the moment of the click and flips what it
// finds there - the same read-the-latest-rather-than-the-
// captured move the updater form makes.
// Run: pnpm koan 09-closures-and-stores/04-updater-form-escape.tsx
import { useState } from 'react';
import { afterEach, expect, it } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
afterEach(cleanup);
// Given: a trainer panel that spends two skill points per click, in the
// value form. Both calls close over the same redraw's `spent` slot.
// Do NOT fix this component.
function GreedyTrainer() {
const [spent, setSpent] = useState(0);
const spendTwo = () => {
setSpent(spent + 1);
setSpent(spent + 1);
};
return <button onClick={spendTwo}>spent:{spent}</button>;
}
// --- TODO 1 ---------------------------------------------------------
// One click runs spendTwo once. `spent` is 0 in the redraw that built
// the handler, so work out what each setSpent call asks for and what
// React ends up committing after the batched click.
// Replace null with the number the button shows after one click.
const SPENT_AFTER_ONE_CLICK: number | null = null; // replace me
// --- TODO 2 ---------------------------------------------------------
// The same panel, but this one you fix. Change the two setSpent calls
// to the updater form, so the second update builds on the first's
// pending result and one click spends two points.
function SteadyTrainer() {
const [spent, setSpent] = useState(0);
const spendTwo = () => {
setSpent(spent + 1); // fix me
setSpent(spent + 1); // fix me
};
return <button onClick={spendTwo}>spent:{spent}</button>;
}
it('TODO 1 — two value-form setSpent calls in one click', () => {
render(<GreedyTrainer />);
fireEvent.click(screen.getByRole('button'));
expect(
screen.getByRole('button').textContent,
'TODO 1 — what one batched click actually commits',
).toBe(`spent:${SPENT_AFTER_ONE_CLICK}`);
});
it('TODO 2 — the updater form stacks: one click spends two', () => {
render(<SteadyTrainer />);
fireEvent.click(screen.getByRole('button'));
expect(
screen.getByRole('button').textContent,
'TODO 2 — one click must move the total by two',
).toBe('spent:2');
});