Skip to content
academia.sh

Lesson 08 / 20

State Management

State management decides where the data that changes over time on a screen is kept: local to a single component or shared and read by many screens. The lesson measures how one state change updates the cart badge and leaves the product list kept.

Published Updated

In this lesson

  • Tells local state, shared state, lifting state up, and the provider apart
  • Measures that a local state change updates only the node that owns it
  • Shows that shared state carries one change to several nodes and raises the command count
  • Shows that when state changes but no visible prop changes, no node is updated
Contents

Navigation determined when screens are mounted and unmounted in the tree. The data inside a screen changes over time: the number of items in a cart, the text in an input field, whether a section is open or closed. This data is called state, and state management means deciding where the state is kept and who reads it.

The question of the course does not change: what does this update do to the node in the view tree? The fate is kept, updated, mounted, or unmounted; the total of the updated, mounted, and unmounted nodes is the commands. We do not assign a fate by hand; the rule gives it:

// fate.mjs — classifies what an update does to each node of the view tree
export const FATES = ["kept", "updated", "mounted", "unmounted"];

export function el(type, props = {}, children = [], key = null) {
  return { type, props, children, key };
}

function segment(node) {
  return node.key === null ? node.type : `${node.type}[${node.key}]`;
}

function identity(node, index) {
  return node.key === null ? `#${index}` : `key:${node.key}`;
}

function sameProps(a, b) {
  const names = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
  return names.every((n) => JSON.stringify(a[n]) === JSON.stringify(b[n]));
}

function whole(node, path, fate, ops) {
  const here = `${path}/${segment(node)}`;
  ops.push({ fate, path: here });
  node.children.forEach((child) => whole(child, here, fate, ops));
}

export function reconcile(prev, next, path = "", ops = []) {
  if (prev === null) {
    whole(next, path, "mounted", ops);
    return ops;
  }
  if (next === null) {
    whole(prev, path, "unmounted", ops);
    return ops;
  }
  if (prev.type !== next.type || prev.key !== next.key) {
    whole(prev, path, "unmounted", ops);
    whole(next, path, "mounted", ops);
    return ops;
  }
  const here = `${path}/${segment(next)}`;
  ops.push({ fate: sameProps(prev.props, next.props) ? "kept" : "updated", path: here });
  const before = new Map(prev.children.map((child, i) => [identity(child, i), child]));
  const matched = new Set();
  next.children.forEach((child, i) => {
    const id = identity(child, i);
    matched.add(id);
    reconcile(before.has(id) ? before.get(id) : null, child, here, ops);
  });
  prev.children.forEach((child, i) => {
    if (!matched.has(identity(child, i))) reconcile(child, null, here, ops);
  });
  return ops;
}

export function printOps(ops) {
  ops.forEach((op) => console.log(`${op.fate}: ${op.path}`));
}

export function countFates(ops) {
  for (const name of FATES) {
    console.log(`${name}: ${ops.filter((op) => op.fate === name).length}`);
  }
  console.log(`commands: ${ops.filter((op) => op.fate !== "kept").length}`);
}

The model builds this fate’s rule; the model is not the framework itself. The rule looks at trees; it does not see where the state sits.

Local State

Local state is data that a single component keeps inside itself. The quantity counter of each row in a cart is an example: nobody outside the row reads this number. In the framework local state is held with a state hook, and the hook asks for the component to be rendered again when the state changes. A render is the component producing its new view tree; producing a tree does not count as a command on its own.

// Platform code: QuantityRow
function QuantityRow({ name }) {
  const [qty, setQty] = useState(1);
  return (
    <Pressable onPress={() => setQty(qty + 1)}>
      <Text>{name}: {qty}</Text>
    </Pressable>
  );
}

This fragment is not measured. In the model, state is the input of the function that builds the tree: the function produces a tree from the state, and the rule compares the previous tree with the next one.

AL1. The model assumes that the functions from state to tree are pure: the same state gives the same tree. Real components also have hooks and side effects, but what this lesson measures is only the tree.

We build a list of two rows. The quantity of each row is its own state, and the quantity of the pen row goes from 1 to 2.

// main.mjs
import { el, reconcile, printOps, countFates } from "./fate.mjs";

function renderRows(counts) {
  return el("List", {}, Object.keys(counts).sort().map((name) =>
    el("Item", { label: name, count: counts[name] }, [], name)));
}

const ops = reconcile(
  renderRows({ pen: 1, notebook: 1 }),
  renderRows({ pen: 2, notebook: 1 }),
);
printOps(ops);
countFates(ops);
kept: /List
kept: /List/Item[notebook]
updated: /List/Item[pen]
kept: 2
updated: 1
mounted: 0
unmounted: 0
commands: 1

When local state changes, only its owner is updated. Item[pen] is updated, because its count prop changed; Item[notebook] and the list are kept. The command count is 1. Keeping the state local keeps the effect of the change local too.

Shared State and Lifting State Up

More than one row reads the cart count. The badge in the top bar, the total in the bottom bar, and the checkout button all look at the same number. This data can no longer live inside a single component, because sibling components cannot read each other’s state. The solution is to move the state to the common ancestor of the components that read it: this operation is called lifting state up. The ancestor holds the state and passes it down as a prop.

If there is a deep chain between the ancestor and the components below it, passing the prop through every layer becomes heavy. Then the shared state is put into a provider: this component, placed at the top of the tree, lets every component below it read the state directly. Shared state libraries offer this mechanism ready-made; the reading component only calls a hook.

// Platform code: CartBadge
function CartBadge() {
  const count = useCartCount();
  return <Text>{count}</Text>;
}

AL2. The model does not show the provider as a node in the view tree; it shows only the nodes that read the count. The cart count is in shared state, and renderCart(state) builds the tree from this state.

// cart.mjs — builds the cart screen's view tree from the shared state
import { el } from "./fate.mjs";

export function renderCart(state) {
  return el("Screen", {}, [
    el("Header", {}, [
      el("Title", { text: "Cart" }),
      el("Badge", { count: state.count }),
    ]),
    el("List", {}, state.items.map((label) => el("Item", { label }, [], label))),
  ]);
}

The user adds one more of a product in the cart. The cart count goes from 2 to 3, and the list of products does not change.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderCart } from "./cart.mjs";

const items = ["pen", "notebook"];
const ops = reconcile(
  renderCart({ count: 2, items }),
  renderCart({ count: 3, items }),
);
printOps(ops);
countFates(ops);
kept: /Screen
kept: /Screen/Header
kept: /Screen/Header/Title
updated: /Screen/Header/Badge
kept: /Screen/List
kept: /Screen/List/Item[pen]
kept: /Screen/List/Item[notebook]
kept: 6
updated: 1
mounted: 0
unmounted: 0
commands: 1

A single state change was made, and two nodes received different fates. /Screen/Header/Badge is updated, because the badge’s count prop changed. The product rows and the list are kept, because their props stayed the same. In M30/K03 the question was what triggers a recomputation. This course does not ask the cause; it classifies the effect: not the cause but the effect, and the same trigger updates one node and keeps another.

The price of sharing is also visible in this measurement. As the number of nodes that read a state grows, the same change produces more commands. We compare this in two setups. In one, the badge keeps its own counter, and the total at the bottom shows another number. In the other, both read the same shared state.

// main.mjs
import { el, reconcile, countFates } from "./fate.mjs";

function renderPair(badge, total) {
  return el("Screen", {}, [
    el("Header", {}, [el("Badge", { count: badge })]),
    el("Footer", {}, [el("Total", { count: total })]),
  ]);
}

console.log("local:");
countFates(reconcile(renderPair(2, 2), renderPair(3, 2)));
console.log("shared:");
countFates(reconcile(renderPair(2, 2), renderPair(3, 3)));
local:
kept: 4
updated: 1
mounted: 0
unmounted: 0
commands: 1
shared:
kept: 3
updated: 2
mounted: 0
unmounted: 0
commands: 2

In the local setup the change updates a single node: commands 1. In the shared setup two nodes are updated: commands 2. In the local setup two places on the screen show different numbers: badge 3, total 2. In the shared setup both show 3, and the price of this is one command. Which setup is right comes not from the command count but from the screen being consistent. The rule does not make this judgment; it tells the price of a command.

Adding an Item to a List

Until now, the state change modified a prop that already existed. Most real changes do not stop at changing a number; they also add an item to a list. When a product is added to the cart, two things happen: the badge count increases and a new row appears in the list. The two effects come from the same state but receive different fates in the tree; we measure this next.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderCart } from "./cart.mjs";

const ops = reconcile(
  renderCart({ count: 2, items: ["pen", "notebook"] }),
  renderCart({ count: 3, items: ["pen", "notebook", "eraser"] }),
);
printOps(ops.filter((op) => op.fate !== "kept"));
countFates(ops);
updated: /Screen/Header/Badge
mounted: /Screen/List/Item[eraser]
kept: 6
updated: 1
mounted: 1
unmounted: 0
commands: 2

The badge is updated, as in the previous measurements. The new row, however, is not updated but mounted: in the previous tree there was no node with this key, and the rule cannot update a node that does not exist. The rows of the first two products match by their keys and stay kept. The key of the rows is the product name; for this reason the rule knows from keyed matching, not from position, that the addition does not touch the old rows.

This measurement also explains a distinction. The state is a single object, but its effect on the tree is in parts: the node that shows the number is updated, the item entering the list is mounted, and the untouched items are kept. To say “one state change is one command” is wrong; the command count depends on how many nodes the change reaches.

Counterexample: The Cost of Lifting Up

Lifting state up is not always a good move. If the quantity of a row concerns only that row, moving it to the shared state at the very top of the screen brings no gain. The data is now the state of the whole screen; the change modifies the state at the top, and every node fed by that state enters the comparison. The two-setup measurement above showed this: in the shared setup a single change was two commands, in the local one it was one. As the number of nodes grows, the difference grows.

The price of leaving it local, however, is inconsistency: if two places show the same number, the two showing different numbers is a bug. The decision is therefore not a rule but a weighing: look at how many places read the data and whether those places need to be consistent. If consistency is needed, one extra command of shared state is accepted; if it is not needed, the state stays local.

Where State Should Live

The place of state is found with three questions. The first is who reads it: if only one component reads it, it stays local. The second is where the common ancestor of the readers is: if two siblings read it, the state lives in the ancestor right above them. The third is whether the readers are in distant corners of the tree: if so, a provider steps in. Keeping the state higher than necessary looks like a convenience at first glance, but with every change all the components that read that state ask for a re-render. A re-render is not a command; the rule compares trees and counts only the difference as commands. Even so, re-rendering many components needlessly means the rendering work grows for nothing, and this work is measured as a separate topic in later lessons.

Common Misconceptions

The first misconception is the idea that “when state changes, the screen changes.” The screen changes only if the function from state to tree writes the change into the tree. As the bounding measurement will show, a field that is not written to the screen updates no node.

The second misconception is that “shared state is always better.” Shared state makes a piece of data readable from many places, but it brings a price too: the change produces as many commands as there are nodes that read it, and it becomes harder to track who changed the data. Data that concerns only its own component, such as the quantity of a row, should stay local.

The third misconception is to choose between local and shared once and leave it. A piece of data may start out local and be lifted when a new reader is added to the screen. Moving it is a change in the arrangement of the code; the nodes in the view tree stay the same, and what changes is only which node reads which state.

The fourth misconception is to store a value that can be derived separately. The badge count can be computed from the length of the list. Keeping it as a separate state creates the possibility of the two values drifting apart: the list may show three products while the badge says two. A value that can be derived is not stored; it is computed on every render. This way, when an item is added to the list, the badge is also updated by the same change, as seen in the addition measurement, and the two cannot drift apart.

The fifth misconception is to assume that the smaller a state is, the more harmless it is. A small state can also be read by many nodes. The cost is determined not by the size of the state but by the number of nodes that read it.

The sixth misconception is to count the measurement of a single state change as the behavior of the whole app. What is measured here is the effect of one change on one cart screen. On another screen, with more readers or a deeper tree, the numbers change; what does not change is the way the fate is classified.

The Bounding Measurement

A state changing and a node being updated are not the same event. Below, a field that is not shown on the screen, coupon, is added to the cart state. First only this field changes, then the count changes.

// main.mjs
import { reconcile, printOps, countFates } from "./fate.mjs";
import { renderCart } from "./cart.mjs";

const items = ["pen", "notebook"];
const focus = (op) => op.path === "/Screen/Header/Badge";
const first = reconcile(
  renderCart({ count: 2, items, coupon: "summer" }),
  renderCart({ count: 2, items, coupon: "winter" }),
);
const second = reconcile(
  renderCart({ count: 2, items, coupon: "winter" }),
  renderCart({ count: 3, items, coupon: "winter" }),
);
console.log("coupon changed:");
countFates(first);
console.log("count changed:");
printOps(second.filter(focus));
coupon changed:
kept: 7
updated: 0
mounted: 0
unmounted: 0
commands: 0
count changed:
updated: /Screen/Header/Badge

The coupon changed, but renderCart does not write this field to the tree; the two trees are the same and commands is 0. The state changed, the tree did not. When the count changes, /Screen/Header/Badge is updated. The rule does not see the place of the state or how much it changed; it sees only the comparison of the props visible in the tree. For this reason state management alone is not a performance measure: a state being updated a lot does not mean it produces many commands.

Summary

  • This lesson’s fate: updated. When the cart count changed, the /Screen/Header/Badge node came out updated, because its count prop changed.
  • The same state change updated the badge and left the product list kept: the same trigger gave two nodes two different fates.
  • Local state keeps the effect of a change on the node that owns it; shared state produces as many commands as there are nodes that read it.
  • When a state that is not written to the tree, such as a coupon, changes, no node is updated: a state change and a command are not the same thing.
  • The rule does not see the place of the state; it only compares the previous and the next tree.

Next Step

In this lesson the state changed inside the app. The app’s most important data source, however, is outside: a server. The next lesson, Networking and Data, measures what the loading indicator and the list go through in the tree when a request returns a response.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close