DEV Community

Cover image for Master React State Like a Samurai — Past vs Present 🥋⚛️#hooks
mayank sagar
mayank sagar

Posted on

Master React State Like a Samurai — Past vs Present 🥋⚛️#hooks

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 tiny usePrevious hook) — 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Why this works

  • prev is 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]);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Usage

const prevUserId = usePrevious(currentUserId);

useEffect(() => {
  if (prevUserId !== currentUserId) {
    // userId just changed — do something
    fetchUserData(currentUserId);
  }
}, [currentUserId, prevUserId]);
Enter fullscreen mode Exit fullscreen mode

Why this works

  • ref.current persists across renders but updating it doesn't cause a re-render.
  • Because the effect that writes ref.current runs after render, the value you read from usePrevious is 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
Enter fullscreen mode Exit fullscreen mode

Toggling reliably

setOpen(prev => !prev);
Enter fullscreen mode Exit fullscreen mode

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)