跪拜 Guibai
← Back to the summary

The Three Iron Rules of React State That Kill 90% of Beginner Bugs

image.png

Core Conclusions (Top of the Pyramid)

React state management follows three iron rules. Understanding them helps you avoid 90% of beginner traps:

  1. State initialization should only execute once, at mount time. Expensive computations must use lazy initialization.
  2. State updates are asynchronous and batched. When the new state depends on the old value, you must use an updater function.
  3. Never store what you can compute. Derived state is the most effective way to eliminate bugs.

Below, we break it down layer by layer, top-down.


Layer 1: Lazy Initialization — Don't Recompute on Every Render

The Problem: How Many Times Does Your Initialization Logic Run?

Look at this code:

image.png

const [users] = useState(heavyComputation());  // ❌ Executes on every render

Intuitively, the initial value of useState is only used during the "first render." But JavaScript evaluates all arguments before calling a function. This means heavyComputation() is called on every render—React simply ignores the result after the first one.

For a heavyComputation() that generates 10,000 user records, this means every keystroke that triggers a re-render wastes 10,000 loop iterations.

The Solution: Pass a Function, Not a Value

const [users] = useState(() => heavyComputation());  // ✅ Executes only on mount

When you pass a function (rather than the result of calling a function), React treats it as a "lazy initializer"—it is called only once, when the component mounts. During subsequent re-renders, this function is completely skipped.

When Do You Need Lazy Initialization?

Scenario Pass Value Directly Lazy Function
Simple literal useState(0) ✅ No overhead Unnecessary
Reading localStorage ❌ Reads disk every time ✅ Reads once
Large data preprocessing ❌ Wastes work every render ✅ Computes once
Complex object construction new on every render ✅ Constructs once

One-sentence rule: If computing the initial value involves a function call and is not O(1), make it lazy.


Layer 2: Async Updates and Batching — setState Is Not a Setter

The Intuition Trap

image.png

This is the most common pitfall for React beginners. Look at this code—what do you expect count to become?

const [count, setCount] = useState(0);

const addCount = () => {
  setCount(count + 3);   // count = 0, expect to become 3
  setCount(count + 3);   // count = 0, expect to become 6
  setCount(count + 3);   // count = 0, expect to become 9
};

Actual result: after clicking, count becomes 3, not 9.

Why?

React's state updates are asynchronous and batched:

  1. The value of count in the current closure is a snapshot of this render and does not change mid-execution.
  2. All three setCount(count + 3) calls compute against the same snapshot: 0 + 3 = 3.
  3. React merges the three updates into one: final state = 3.

The design reason behind this is identical to the DocumentFragment in test.html:

// test.html — The native DOM concept of batched updates
const fragment = document.createDocumentFragment();
for (const task of data) {
  const item = document.createElement('li');
  item.innerText = task;
  fragment.appendChild(item);   // Append to an in-memory, invisible fragment first
}
oList.appendChild(fragment);    // Insert into the real DOM all at once, triggering only one reflow

React's batched updates are the framework-level implementation of this exact idea—multiple setState calls are collected and trigger a single re-render at the appropriate time. If every setState call caused an immediate re-render, performance would be unacceptable.

The Correct Approach: Updater Functions

When the new state depends on the old state, pass a function, not a value:

setCount(prevCount => prevCount + 3);  // prev = 0 → returns 3
setCount(prevCount => prevCount + 3);  // prev = 3 → returns 6
setCount(prevCount => prevCount + 3);  // prev = 6 → returns 9

Each updater function receives prevCount as the latest value from the previous update, not the snapshot from the closure. React guarantees that updater functions execute in order, with the result of one serving as the input to the next.

Decision Rule

Does the new state depend on the old state?
  ├── No → setState(newValue)
  └── Yes → setState(prev => newValue)

Recommendation: When in doubt, always use an updater function. Its behavior is deterministic and avoids closure traps.


Layer 3: Derived State — Don't Store What You Can Compute

A Counter-Intuitive Fact

The more state you maintain in your application, the higher the probability of introducing bugs. The reason is simple:

Every new piece of state introduces a "keep in sync" responsibility—when A changes, B must follow. Forget once, and you have a bug.

So the highest form of state management is not managing state.

See the Practice in App.jsx

image.png

const [users] = useState(() => heavyComputation());   // The single source of truth
const [filterText, setFilterText] = useState('');      // The only control signal

// Don't create a third state; just compute it
const filteredUsers = users.filter(user => user.name.includes(filterText));

The key design decision here: filteredUsers is not state; it is a derived value.

Why do this?

The Three-Question Test for Identifying "Fake State"

Before you write useState, ask yourself:

  1. Can this value be computed from existing state/props? → If yes, compute it directly; don't create state.
  2. When this value changes, must another state follow? → If yes, the latter is likely a derived value.
  3. Does this value have an independent lifecycle? → If not (it's just a projection of something else), it's a derived value.

A Classic Example

// ❌ Fake state: three pieces of state depending on each other, prone to inconsistency
const [items, setItems] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);    // Computable from items
const [itemCount, setItemCount] = useState(0);       // Computable from items

// ✅ Single source of truth + derivation
const [items, setItems] = useState([]);
const totalPrice = items.reduce((sum, i) => sum + i.price, 0);  // Derived
const itemCount = items.length;                                   // Derived

The Full Pyramid Picture

Stringing the three layers together, the complete mental model for React state management is:

┌─────────────────────────────────────────────┐
│  Layer 1: Initialization                     │
│  useState(() => expensiveWork())  — Compute once │
├─────────────────────────────────────────────┤
│  Layer 2: Update                             │
│  setState(prev => ...)  — Async, batched, reliable │
├─────────────────────────────────────────────┤
│  Layer 3: Consumption                         │
│  const derived = compute(state) — Don't store redundancy │
└─────────────────────────────────────────────┘

The underlying driver for these three principles comes from a single source: performance.

What is the essence of React? Using a declarative syntax to let the framework find the shortest path between "data changes" and "DOM changes." These three principles are how you help React help you.


Appendix: Complete Code File Reference

This analysis is based on the following three demo files:

File Core Knowledge Point
test.html DocumentFragment batched DOM operations—prerequisite knowledge for understanding React's batched updates
App.jsx Lazy initialization useState(fn) + derived state filteredUsers + performance.now() performance measurement
App2.jsx setState(prev => ...) updater function vs. the closure trap of passing a value directly