Lesson 07 / 25
Local State
The value that lives across a component's renders; binding state slots to call order, queuing an update, batching the updates within a single round, the stale value trap, and where in the tree state belongs.
Contents
The previous lesson worked out how the tree gets updated: the trees produced by two renders are compared, and the difference between them is applied to the document with the smallest possible touches. Once reconciliation finishes, one question remains. The comparison always happens after a re-render — so what starts a re-render?
The answer is a value the component holds within itself. On the North Slope Measurement Station page, the filter panel knows which measurement type is selected, the measurement table knows which column it is sorted by, and each measurement badge knows whether it is expanded. These values do not come from the server, are not passed down from a parent component, and live until the page reloads. This lesson calls them local state; its subject is where state is stored, when an update becomes visible, and how many renders three updates within a single round produce.
Where State Lives
A component’s body is a function that runs from scratch on every render. Local variables defined inside the function body are reset on every call; the second render carries nothing over from the first. State has to live somewhere outside this body.
That place is the component’s instance. Every component position in the tree keeps a matching instance record, and that record holds a list of slots. While the body runs, the runtime keeps a cursor: each time the body requests state, the cursor advances by one and hands back the slot at that position. If no slot exists yet on the first render, one opens with the initial value; on later renders, the existing slot is read.
The function the body calls to reach this slot is called a state hook. The hook returns two things: the slot’s current value and a function that sends a write request to that slot. The value is read, the write request is invoked; there is no direct assignment between the two.
An Update Is a Request, Not an Assignment
The name of the write function can be misleading. It does not write to the slot the moment it is called; it places an update request in a queue and marks that component dirty. The queue is flushed after the running code finishes. The model below makes this queue, its flushing, and how many renders the flush produces, visible.
// state-queue.mjs — local state, the update queue, and batching let activeInstance = null; let renderCount = 0; const queue = []; function stateHook(initial) { const inst = activeInstance; const i = inst.cursor++; if (inst.slots.length === i) inst.slots.push(typeof initial === "function" ? initial() : initial); return [inst.slots[i], (next) => { queue.push({ inst, i, next }); }]; } function render(instance) { activeInstance = instance; instance.cursor = 0; const output = instance.body(); activeInstance = null; renderCount++; console.log(` render ${renderCount}: ${output}`); } // Runs when the event handler finishes: the queue is flushed, each instance renders once. function endRound() { const dirty = []; for (const job of queue) { const previous = job.inst.slots[job.i]; job.inst.slots[job.i] = typeof job.next === "function" ? job.next(previous) : job.next; if (!dirty.includes(job.inst)) dirty.push(job.inst); } console.log(` [end of round] queue length ${queue.length}, dirty instances: ${dirty.length}`); queue.length = 0; for (const inst of dirty) render(inst); } const badge = { name: "badge", slots: [], cursor: 0, external: null }; badge.body = () => { const [value, setValue] = stateHook(0); const [unit, setUnit] = stateHook("C"); badge.external = { value, setValue, setUnit }; return `value=${value} unit=${unit}`; }; console.log("A. three updates writing the value directly, within a single event"); render(badge); badge.external.setValue(badge.external.value + 1); badge.external.setValue(badge.external.value + 1); badge.external.setUnit("F"); console.log(` value in the body right after writing: ${badge.external.value}`); endRound(); console.log("B. the same operation, with an updater function"); badge.slots = [0, "C"]; renderCount = 0; render(badge); badge.external.setValue((previous) => previous + 1); badge.external.setValue((previous) => previous + 1); badge.external.setUnit("F"); endRound(); console.log("C. the same two updates made in two separate rounds"); badge.slots = [0, "C"]; renderCount = 0; render(badge); badge.external.setValue((previous) => previous + 1); endRound(); badge.external.setValue((previous) => previous + 1); endRound();
A. three updates writing the value directly, within a single event render 1: value=0 unit=C value in the body right after writing: 0 [end of round] queue length 3, dirty instances: 1 render 2: value=1 unit=F B. the same operation, with an updater function render 1: value=0 unit=C [end of round] queue length 3, dirty instances: 1 render 2: value=2 unit=F C. the same two updates made in two separate rounds render 1: value=0 unit=C [end of round] queue length 1, dirty instances: 1 render 2: value=1 unit=C [end of round] queue length 1, dirty instances: 1 render 3: value=2 unit=C
Batching
The first thing to read in section A is that the value in the body is still zero right after the write calls. The body holds the value it read during that render in a closure; the closure belongs to that render, and the write request does not change it. State is constant for the duration of a render. This is not a limitation but a guarantee: within the same body it is impossible for a value to read as old in one place and new in another.
The second thing to read is the render count. Three updates entered the queue, the queue was flushed at the end of the round, and exactly one render happened. Accumulating every update within a round and settling it with a single render is called batching. Without batching, three writes would produce three renders; since each render means a reconciliation round, the cost would triple. Worse, the intermediate renders would look inconsistent: the value updated while the unit was still old.
The boundary of batching is the round boundary. In section C, the same two updates were made in two separate rounds and produced two renders. Updates made within a single event handler fall into the same group; updates coming from a timer callback and a network response are separate rounds and typically produce separate renders.
Stale Values and the Updater Function
The only difference between A and B is the form of the write call; the results diverge to one and two.
In A, both calls computed value + 1 from the zero in their own closure, and both wrote
one. The second write does not overwrite the first; it writes the same result again. This
is the stale value trap, and it appears in any code that writes to the same slot
twice within a single round: a counter has advanced by one after two increments.
In B, the write call was given a function instead of a value. As the queue is flushed, this function is called with the slot’s value at that moment; the first request moves it from zero to one, the second from one to two. This yields a binding rule: if the new value depends on the old value, an updater function is written. If the new value is independent of the old one — the filter’s selected type, whether the panel is open — writing the value directly is enough.
The Invariance of Call Order
In the model, slots are located by cursor, not by name. This has a cost: the body must call the state hook in the same order and the same number of times on every render. If a hook call is placed inside a condition, the cursor shifts on the render where the condition changes, and the second slot’s value is read from the first slot. The failure is silent; two unrelated values swap places.
This is why state calls are written at the top of the body, unconditionally and outside any loop. If conditional state is genuinely needed, the condition goes around the use that follows the hook, not around the hook itself. Designs that resolve by name do not carry this constraint, but in exchange they must check for name collisions. The gain of an order-based design is that it locates a slot in constant time without keeping any name registry.
Where State Sits in the Tree
Which component holds a piece of state is a design decision with a directly measurable cost. When a slot changes, that component and its entire subtree re-render; siblings and ancestors are unaffected.
// state-location.mjs — render cost of holding the same state in two different nodes const TREE = { name: "page", children: [ { name: "title", children: [] }, { name: "filter-panel", children: [{ name: "filter-button", children: [] }, { name: "filter-list", children: [] }] }, { name: "measurement-table", children: [{ name: "row-1", children: [] }, { name: "row-2", children: [] }, { name: "row-3", children: [] }] }, ], }; function find(node, name) { if (node.name === name) return node; for (const c of node.children) { const s = find(c, name); if (s) return s; } return null; } // The node holding the state and its entire subtree are re-rendered. function subtree(node, collected = []) { collected.push(node.name); for (const c of node.children) subtree(c, collected); return collected; } const total = subtree(TREE).length; for (const owner of ["page", "filter-panel"]) { const rendered = subtree(find(TREE, owner)); console.log(`state in node "${owner}":`); console.log(` re-rendered ${rendered.length}/${total}: ${rendered.join(", ")}`); }
state in node "page": re-rendered 9/9: page, title, filter-panel, filter-button, filter-list, measurement-table, row-1, row-2, row-3 state in node "filter-panel": re-rendered 3/9: filter-panel, filter-button, filter-list
Whether the filter panel is open concerns only the panel. If this value is held at the page root, every open and close re-renders nine components; if it is held in the panel, it re-renders three. Holding state in the lowest component that uses it is called state colocation, and it is the default preference.
The opposite direction is also necessary. Both the filter panel and the measurement table have to know the selected measurement type. If two copies of the same value are held in two separate slots, they inevitably drift apart. Such a value is held in the two components’ common ancestor and passed down; the Component Concept lesson called this lifting state up. The criterion can be written in one sentence: state sits at the nearest common ancestor of every component that needs it — no higher, no lower.
The Initial Value and Lazy Initialization
The hook in the model uses the initial value only when the slot first opens; on later renders that value is never read. This has a consequence: if the initial value comes from an expensive computation — reading from local storage, parsing a long list — the computation runs to no purpose on every render. If the hook also accepts a function in place of a value, the computation runs only on the first render. This is called lazy initialization.
A second consequence trips people up more often: when a value coming from a parent component is passed as the initial value, that value later changing does not update the slot. The slot froze on the first render. For a view that needs to track the value coming from above, that value should not be state; it should be used directly.
Summary
- Local state lives in a slot on the component instance, not in the component body; the body reads slots by call order on every render.
- The write function does not assign; it places an update request in the queue. The value read stays constant for the duration of a render.
- All updates within a round are batched and produce a single render; updates in separate rounds produce separate renders.
- If the new value depends on the old value, an updater function is written; writing the value directly produces a stale value on the second update within the same round.
- State hooks are called unconditionally and outside loops, because slots are located by order, not by name.
- State is held at the nearest common ancestor of the components that use it; holding it higher produces unnecessary renders, holding it in two places produces drift.
Next Step
The loop this lesson builds is self-contained: state changes, the body reruns, the tree reconciles. The body has exactly one job in this loop, and that is computing an output from inputs. Yet part of what the measurement station page has to do falls outside this computation: fetching measurements from the server, attaching a listener to a scroll event, writing the selected filter to local storage, setting up a timer. None of these can be written inside the body — the body computes an output, it does not touch the outside world. The next lesson takes up where this work is written, when it runs, and when what it sets up has to be torn down.
To keep your progress and take notes, Log in
My notes
Log in to take notes.