Short version: most React bugs aren’t logic bugs — they’re time bugs. You update state, you expect a result, and React gives you a snapshot instead of live reality. Learn three stances to handle it: snapshot updates, functional updates, and cross-render comparison (useRef / usePrevious). This article explains those patterns with clean mental models and small code snippets so you can stop fighting React and start working with it.
Why a samurai?
A samurai trains stances to match forces that come at them.
React gives you a snapshot of a component at render time — it’s a frozen stance. If you try to act like it’s “live” you’ll get stale behavior. Think in stances and timing, not brute-force updates.
TL;DR (read this first)
-
setState(value)uses the snapshot you read in that render → can be stale. -
setState(prev => next)uses React’s queued/most-current value → reliable for sequential updates. - To compare previous vs current values across renders, use
useRef(a tinyusePrevioushook) — it stores the last render’s value without forcing re-renders.
1) The battlefield: renders = snapshots
When a component renders, React provides you a snapshot of props and state at that render. That snapshot is immutable for the duration of that render. If you call the updater with setState(count + 1) multiple times in the same tick, every call sees the same count value that existed at the start of the render. That’s why the result can be surprising.
Mental model: a render is a photograph. If you update three times inside the same frame and you always read from the photograph, you’ll increment the same number three times on paper — not in the next frame.
2) The snapshot trap (what goes wrong)
const handleAdd = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};
// Expected: 3
// Result: 1
Why: count here is the captured value from the render when handleAdd was created. React batches the updates and applies them on top of the same starting snapshot (unless you use function updaters).
3) The functional defense (the stance that never misses)
Use the updater form so each update reads the latest pending state:
const handleAdd = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
};
// Result: 3
Why this works
-
previs provided by React as the most recent state (including queued updates). - Each updater is applied in order, so updates are sequential and reliable.
When to use
- Any time your new state depends on the previous state (increments, toggles, push-to-array, etc.).
4) The tracker’s challenge — detecting change
Sometimes you want to run an effect only when a specific value changed from its previous render value.
useEffect(() => {
fetchUserData(currentUserId);
}, [currentUserId]);
This runs whenever currentUserId changes — but inside the effect you only see the current value, not the previous one. useEffect tells you something changed, not what it changed from. If you need to know whether currentUserId changed from a particular previous value (or changed at all compared to last render), you must keep the previous value yourself.
Mental model: you see a fresh footprint in mud — you don’t automatically know who made it unless you kept the old print to compare with.
5) The usePrevious scroll — time-travel without breaking React
useRef stores a value across renders without triggering re-renders. Combine it with useEffect and you get a tiny usePrevious hook:
import { useRef, useEffect } from "react";
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value; // update AFTER render
}, [value]);
return ref.current; // the previous render's value
}
Usage
const prevUserId = usePrevious(currentUserId);
useEffect(() => {
if (prevUserId !== currentUserId) {
// userId just changed — do something
fetchUserData(currentUserId);
}
}, [currentUserId, prevUserId]);
Why this works
-
ref.currentpersists across renders but updating it doesn't cause a re-render. - Because the effect that writes
ref.currentruns after render, the value you read fromusePreviousis the previous render’s value.
Mental model: the sensei keeps a scroll of yesterday’s note. He can compare it to today’s situation without shouting and reconfiguring the dojos.
6) Master the stances — short checklist
Snapshot update:
setState(newValue)
Use when you replace state with a value that doesn’t depend on the previous state.Sequential update:
setState(prev => next)
Use when next depends on prev (increments, toggles, array pushes). This is your defensive stance.Cross-render comparison:
useRef/usePrevious
Use when you must detect transitions — "did X change from Y to Z?"
Practical examples & gotchas
Updating arrays safely
setTodos(prev => [...prev, newTodo]); // use prev
Toggling reliably
setOpen(prev => !prev);
Avoid trying to read "latest" state synchronously after setState
setState is asynchronous; don't assume state was updated immediately after the call. Use effects or callbacks where necessary.
Final thought
Most React headaches are about time, not logic. Think in renders (snapshots), not in instant live values. When you shift to the right stance — snapshot, functional, or cross-render memory — your code becomes calmer, predictable, and sharper.
Which stance are you practicing today? ⚔️🧭📜
Top comments (0)