React useState's Three Hidden Behaviors That Trip Up Beginners
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.
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.
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.