跪拜 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 ↗