Skip to content

useState Internals and Functional Updates

intermediate12 min read

useState Is useReducer in Disguise

Want to know a secret? Inside React's source code, useState is implemented as a thin wrapper around useReducer:

// Simplified React source (packages/react-reconciler)
function mountState(initialState) {
  const hook = mountWorkInProgressHook();

  if (typeof initialState === 'function') {
    initialState = initialState(); // Lazy initialization
  }

  hook.memoizedState = hook.baseState = initialState;

  const queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  };
  hook.queue = queue;

  const dispatch = (queue.dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue));

  return [hook.memoizedState, dispatch];
}

function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}

The basicStateReducer reveals the duality: if you pass a value, it replaces state. If you pass a function, it calls the function with current state.

Mental Model

Think of useState's update queue as a conveyor belt at a factory. Each setState call places an instruction on the belt. When React processes the queue, it runs through every instruction in order, feeding each one the result of the previous instruction. A value instruction (setState(5)) replaces what is on the belt. A function instruction (setState(prev => prev + 1)) transforms what is on the belt. The final result at the end of the belt becomes the new state.

The Update Queue

So what actually happens when you call setState? React does not update state immediately. It creates an update object and adds it to a circular linked list (queue):

// Simplified update object
{
  action: valueOrFunction,  // What you passed to setState
  hasEagerState: false,     // Optimization flag
  eagerState: null,         // Pre-computed state (if possible)
  next: nextUpdate,         // Circular linked list
}

Eager State Computation

React has an optimization: if the component is not already rendering, it tries to compute the new state immediately:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(1); // React can eagerly compute: Object.is(0, 1) → false → schedule render
  }
}

If Object.is(currentState, eagerState) is true, React bails out without scheduling a render. This is why setCount(count) with the same value does not trigger a re-render (after the first time).

When eager state fails

Eager state computation only works when there are no pending updates in the queue. If another setState is already pending, React cannot determine the current state without processing the entire queue first. In that case, it skips the eager check and always schedules a render. This is why the "first click re-renders, subsequent don't" behavior occurs: the first click has no pending updates so eager comparison works. But if state was recently updated, the queue may not be empty.

Lazy Initialization

This is a small optimization that makes a big difference. If computing the initial state is expensive, pass a function:

// BAD — createInitialTodos() runs on EVERY render
const [todos, setTodos] = useState(createInitialTodos());

// GOOD — createInitialTodos() runs only on mount
const [todos, setTodos] = useState(() => createInitialTodos());

// Also GOOD for parsing from localStorage
const [settings, setSettings] = useState(() => {
  const stored = localStorage.getItem('settings');
  return stored ? JSON.parse(stored) : defaultSettings;
});

The function form (lazy initializer) is called only during mount. On subsequent renders, React reads the stored state from the hook and ignores the initializer entirely.

Common Trap

A common mistake is passing an expensive computation directly: useState(heavyComputation()). This call happens during render, on every render. Even though React ignores the result after mount, the computation still runs. The function form useState(() => heavyComputation()) ensures it runs only once.

Object.is and the Bailout

React uses Object.is (not ===) to compare previous and next state:

Object.is(0, 0);           // true  → no re-render
Object.is('hello', 'hello'); // true  → no re-render
Object.is(NaN, NaN);       // true  → no re-render (unlike ===)
Object.is(0, -0);          // false → re-render (unlike ===)
Object.is({}, {});          // false → re-render (different references)

The critical implication for objects and arrays:

const [items, setItems] = useState([1, 2, 3]);

// This triggers re-render (new array):
setItems([1, 2, 3]);

// This does NOT trigger re-render (same reference):
setItems(items);

// This SHOULD trigger re-render but DOESN'T (mutation + same ref):
items.push(4);
setItems(items); // Object.is(items, items) → true → BAIL OUT

// Correct: create new array
setItems([...items, 4]);

Functional Updates: When and Why

Functional updates aren't just a nice-to-have — they solve two problems that will definitely bite you:

Problem 1: Stale Closures

function Counter() {
  const [count, setCount] = useState(0);

  function incrementThree() {
    // BUG: All three read the same stale count (0)
    setCount(count + 1); // 0 + 1 = 1
    setCount(count + 1); // 0 + 1 = 1
    setCount(count + 1); // 0 + 1 = 1
    // Result: 1

    // FIX: Functional updater chains correctly
    setCount(prev => prev + 1); // 0 → 1
    setCount(prev => prev + 1); // 1 → 2
    setCount(prev => prev + 1); // 2 → 3
    // Result: 3
  }
}

Problem 2: Stale Values in Callbacks

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // BUG: count is always 0 (closure captures initial value)
      setCount(count + 1); // Always 0 + 1 = 1

      // FIX: Functional updater reads latest state
      setCount(prev => prev + 1); // Works correctly
    }, 1000);
    return () => clearInterval(id);
  }, []); // Empty deps — closure captures count = 0

  return <div>{count}</div>;
}

Production Scenario: Optimistic Update with Rollback

function TodoItem({ todo, onUpdate }) {
  const [optimisticTitle, setOptimisticTitle] = useState(todo.title);
  const [isSaving, setIsSaving] = useState(false);

  async function handleSave(newTitle) {
    const previousTitle = optimisticTitle;
    setOptimisticTitle(newTitle); // Optimistic update
    setIsSaving(true);

    try {
      await onUpdate(todo.id, newTitle);
    } catch {
      setOptimisticTitle(previousTitle); // Rollback on failure
    } finally {
      setIsSaving(false);
    }
  }

  return (
    <div>
      <span>{optimisticTitle}</span>
      {isSaving && <span>Saving...</span>}
    </div>
  );
}
Execution Trace
setState(fn)
setCount(prev => prev + 1)
Update object { action: fn } pushed to queue
setState(fn)
setCount(prev => prev + 1)
Second update pushed to same queue
Schedule
React schedules a re-render
Component marked as needing update
Process queue
baseState=0 → fn(0)=1 → fn(1)=2
Queue processed in order, each fn gets latest
Object.is
Object.is(0, 2) → false
New state differs — re-render proceeds
Render
Component called with count=2
Hook returns the final processed value
What developers doWhat they should do
Using value form when update depends on previous state: setCount(count + 1)
The value form captures count from the current closure. Multiple calls in the same event handler all read the same stale value.
Use functional form: setCount(prev => prev + 1)
Passing expensive computation directly: useState(heavyFn())
heavyFn() runs on every render even though React ignores the result after mount. The function form runs only once.
Pass a function: useState(() => heavyFn())
Mutating arrays/objects and calling setState with the same reference
Object.is compares references. Same reference = same state = no re-render. The mutation exists but React never shows it.
Always create new references: setItems([...items, newItem])
Expecting state to update synchronously after setState
setState queues an update and schedules a re-render. The state variable in the current closure never changes.
State is available in the next render. Use functional updater to chain updates.
Quiz
What is the final value of count after this handler runs?
Quiz
Why does useState use Object.is instead of === for comparison?
Quiz
What happens internally when you call useState() during mount vs update?
Key Rules
  1. 1useState is implemented as useReducer with a basic reducer — value replaces, function transforms
  2. 2Updates are queued in a circular linked list and processed in order during the next render
  3. 3Object.is comparison drives bailout — same reference means no re-render
  4. 4Use functional updaters (prev => next) when state depends on previous state or in stale closures
  5. 5Use lazy initializer (useState(() => expensive())) for expensive initial computations

Challenge: Predict the Queue

Challenge: State Queue Processing

// What is the final value of count after handleClick runs?

function App() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(prev => prev + 1); // ?
    setCount(prev => prev * 2); // ?
    setCount(10);                // ?
    setCount(prev => prev + 3); // ?
  }

  return <button onClick=`{handleClick}`>`{count}`</button>;
}
Show Answer

Processing the queue from base state 0:

  1. prev => prev + 1: fn(0) = 1
  2. prev => prev * 2: fn(1) = 2
  3. 10: value replaces state = 10
  4. prev => prev + 3: fn(10) = 13

Final count: 13

The value update (setCount(10)) discards everything before it. The functional updater after it chains from 10. This is why mixing value updates and functional updates requires careful thought about ordering.