useState Internals and Functional Updates
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.
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.
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>
);
}
| What developers do | What 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. |
- 1useState is implemented as useReducer with a basic reducer — value replaces, function transforms
- 2Updates are queued in a circular linked list and processed in order during the next render
- 3Object.is comparison drives bailout — same reference means no re-render
- 4Use functional updaters (prev => next) when state depends on previous state or in stale closures
- 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:
prev => prev + 1: fn(0) = 1prev => prev * 2: fn(1) = 210: value replaces state = 10prev => 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.