React Derived State Is Always One Step Behind - Stop Using useEffect
5 min read
“You synced one piece of state to another with useEffect, and it works - except it's always exactly one render late. The fix isn't a better effect. It's no effect at all.”
You have two numbers on screen. One is count. The other is count * 2,
labeled "Doubled."
Click the increment button. Count updates immediately, like it should.
Doubled does not move. It stays at whatever it was when the page loaded -
in this case, 0 - forever.
This reads like a sensible plan: "whenever count changes, recompute
doubled to match." A useEffect that watches count and calls
setDoubled sounds like exactly the tool for "whenever X changes, do Y."
Except the dependency array here is [] - so the effect runs once, on
mount, computes 0 * 2 = 0, sets doubled to 0, and never runs again.
Click Increment ten times: count becomes 10, the effect that's supposed
to keep doubled in sync never re-fires, and doubled is still 0.
Symptom
Count responds to every click instantly. Doubled does not respond at
all - it's frozen at its initial value no matter how many times you click.
This actually works, in the sense that doubled now updates. But it
introduces a one-render lag that's easy to miss in a quick test and very
visible under fast interaction.
Click Increment. count becomes 1. React re-renders - Count: 1,
Doubled: 0 (the old value, because the effect hasn't run yet for this
render). After that render commits, React runs the effect, which calls
setDoubled(2), which schedules another render - Count: 1, Doubled: 2, finally.
Every click produces two renders instead of one, and for one frame, the
two numbers on screen are out of sync with each other - Doubled is always
showing the relationship for the previous value of count. Click rapidly
enough, or test with multiple increments in a row, and Doubled visibly
lags one step behind Count.
The Sensei's Hint
This is a real, working fix for the original bug - "Doubled" does update
now. But "it works, with a one-render lag" is itself the signature of
derived state implemented with useEffect. If a value can be computed
directly from other state, an effect-based sync will always be at least
one render behind that computation.
No useState for doubled. No useEffect. Just a const that runs every
time the component renders, computing count * 2 from whatever count
currently is, right now, in this render.
Click Increment. count becomes 1. The component re-renders. The line
const doubled = count * 2 runs again - this time with count equal to
1 - and produces 2. Both numbers update in the exact same render. There
is no second render, no lag, no window where they disagree.
useEffect is the tool for "when X changes, do Y" - and "when count
changes, update doubled" sounds exactly like that. The distinction that
matters is what kind of thingY is.
If "do Y" means causing something outside React - a network request, a
subscription, writing to localStorage, focusing a DOM node - that's a side
effect, and useEffect is correct.
If "do Y" means computing a value that React itself will render - and
that value is a pure function of state or props you already have - it isn't
a side effect at all. It's just a calculation, and calculations belong in
the render body, recomputed fresh every time, the same way count + 1 or
items.length would be.
Constraints
Ask: "could I write this as const x = someExpression(otherState),
directly in the component body, with no hook?" If yes - and the expression
isn't expensive enough to need useMemo - that's the entire implementation.
Reaching for useState + useEffect to keep a derived value "in sync"
produces a value that is, structurally, always one render behind the thing
it's derived from.
If the calculation is genuinely expensive - filtering a huge list, running a
heavy computation - a plain const would still be correct, but it would
re-run on every render of the component, including renders triggered by
unrelated state changes. useMemo lets you cache the result and only
recompute when its specific dependencies change:
For count * 2, this is overkill - the calculation is cheaper than the
useMemo bookkeeping itself. But the shape is the same as the plain
const version: synchronous, same-render, no lag. The choice between a
plain const and useMemo is a performance question. The choice between
either of those and useState + useEffect is a correctness question.
"Doubled Counter Frozen at Zero Never Updates" is the single kata for this
pattern, and it's worth working through twice: once to get doubled
updating at all (the useEffect with [count] version, watching for the
one-render lag), and a second time to delete the effect entirely and replace
it with a plain const. The second pass is the one that should feel like
the real fix.