跪拜 Guibai
← All articles
React.js

React useState's Three Hidden Behaviors That Trip Up Beginners

By 绿色蟑螂 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Misreading stale state after `setState` and accidentally re-running expensive initializers are two of the most common React bugs in production. Knowing when to reach for a functional updater and when to pass a function to `useState` eliminates entire categories of subtle, hard-to-reproduce issues.

Summary

Calling `setState` doesn't change the value right away. React queues the update and processes it after the current code block finishes, which is why logging a state variable immediately after setting it still shows the old value. This batching also means three consecutive `setCount(count + 1)` calls all see the same stale `count` and produce a net increase of 1, not 3.

Switching to a functional updater — `setCount(prev => prev + 1)` — fixes that because React chains the callbacks, feeding each one the previous return value. The same queuing model explains why passing `useState(heavyComputation())` runs the expensive function on every render, while `useState(() => heavyComputation())` runs it only once: React skips function arguments after mount.

A final pattern worth internalizing is derived state. When a value can be computed from existing state — a filtered list from a full dataset and a search string — keeping it as a plain variable avoids synchronization bugs and redundant state management.

Takeaways
— `setState` queues an update; the state variable holds its old value until the component re-renders.
— React batches multiple `setState` calls inside the same event handler, so three `setCount(count + 1)` calls all see the same `count`.
— Use a functional updater (`prev => prev + 1`) when the new state depends on the previous state to avoid stale closures.
— Passing a function to `useState` — `useState(() => expensiveWork())` — runs the initializer only on mount, not on every render.
— Values computable from existing state should remain plain variables rather than additional state to prevent sync bugs.
Conclusions

The mental model of a render as an independent function-call snapshot explains all three behaviors — stale logs, batching, and lazy initialization — without memorizing rules.

React's batching is an optimization, not a bug, but the API surface makes it look like a gotcha because the setter name implies immediacy.

Derived state is a design discipline that pays off quickly: every piece of redundant state is a future source of inconsistency.

Concepts & terms
Batching
React's optimization that groups multiple state updates triggered in the same event handler into a single re-render, processing them together after the handler finishes.
Functional update
Passing a callback like `prev => prev + 1` to a state setter instead of a raw value. React chains these callbacks so each receives the most recent computed state, avoiding stale closures.
Lazy initialization
Passing a function to `useState` (e.g., `useState(() => compute())`) so the expensive computation runs only on the component's first mount, not on every subsequent render.
Derived state
A value computed from existing state or props during render, kept as a plain variable rather than stored as separate state, to avoid synchronization overhead and bugs.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗