The Three Iron Rules of React State That Kill 90% of Beginner Bugs
Core Conclusions (Top of the Pyramid)
React state management follows three iron rules. Understanding them helps you avoid 90% of beginner traps:
- State initialization should only execute once, at mount time. Expensive computations must use lazy initialization.
- State updates are asynchronous and batched. When the new state depends on the old value, you must use an updater function.
- 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:
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
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:
- The value of
countin the current closure is a snapshot of this render and does not change mid-execution. - All three
setCount(count + 3)calls compute against the same snapshot:0 + 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
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?
- If
filteredUserswere an independent state, every timeusersorfilterTextchanged, you would have to remember to callsetFilteredUsers(...). Forget once, and the data and UI are out of sync. - As a derived value, it is declarative—"filteredUsers always equals users filtered by filterText." There is no possibility of "forgetting to update."
The Three-Question Test for Identifying "Fake State"
Before you write useState, ask yourself:
- Can this value be computed from existing state/props? → If yes, compute it directly; don't create state.
- When this value changes, must another state follow? → If yes, the latter is likely a derived value.
- 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.
- Lazy initialization → Avoid wasted computation
- Async batched updates → Avoid wasted renders (just like DocumentFragment avoids wasted reflows)
- Derived state → Avoid wasted synchronization
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 |