---
title: 'Local State'
source: 'https://academia.sh/en/courses/component-based-development/local-state'
course: 'Component-Based Interface Development'
language: en
updated: '2026-08-17T18:11:03+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```js
// 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.

```js
// 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.
