React

What happens when the useState setter function is called in React?

The short answer

When you call a useState setter, React doesn't update the state value immediately. It enqueues the update, schedules a re-render, and the new value is available on the next render. The current render still sees the old value because each render captures a snapshot of state at the time it was created. React also batches multiple setter calls into a single re-render for performance and uses Object.is to bail out if the new value is the same as the current one.

Step by step: what actually happens

Let's trace through the lifecycle of a setter call:

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

const handleClick = () => {
setCount(1);
console.log(count); // Still 0!
};

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

When you click the button and setCount(1) runs:

  1. React enqueues the update. It doesn't change count right now. It adds the update to an internal queue for this component.
  2. React schedules a re-render. It marks the component as needing to update, but doesn't re-render immediately.
  3. The event handler continues running. console.log(count) still prints 0 because count in this closure refers to the value from the current render.
  4. After the event handler finishes, React processes the queued updates and re-renders the component.
  5. On the new render, useState(0) returns [1, setCount]. Now count is 1 and the button displays the new value.

The key insight is that count isn't a variable that gets mutated. It's a constant that belongs to a specific render. Each render has its own snapshot of state.

The stale closure problem

This snapshot behavior catches a lot of developers off guard. Here's the classic example:

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

const handleClick = () => {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
};

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

You might expect clicking the button to set count to 3. It doesn't — it sets it to 1. All three calls see the same count value (0) because they all run within the same render. Each call says "set count to 0 + 1," so the final result is 1.

This is the stale closure in action. The count variable is captured by the closure when the render happens, and it doesn't change during the event handler.

The functional updater form

The fix is the functional updater form. Instead of passing a value, you pass a function that receives the previous state as its argument:

const handleClick = () => {
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
};

Now each update builds on the result of the previous one. React processes them in sequence: 0 → 1 → 2 → 3. The final value is 3.

Use the functional form whenever your new state depends on the previous state. The direct form (setCount(count + 1)) is fine when you're setting a completely new value that doesn't depend on what was there before, like setCount(0) to reset or setName('Alice') to set a known value.

Batching

React batches multiple state updates that happen during the same event into a single re-render. This has been true inside event handlers since React's early days, and since React 18, it applies everywhere — including setTimeout, promises, and native event listeners.

function Form() {
const [name, setName] = useState('');
const [submitted, setSubmitted] = useState(false);

const handleSubmit = () => {
setName('Alice');
setSubmitted(true);
// React does NOT re-render after setName.
// It waits until both updates are queued, then re-renders once.
};

return <button onClick={handleSubmit}>Submit</button>;
}

Without batching, setName would trigger a render, then setSubmitted would trigger another render — two renders for one user action. With batching, React groups them and re-renders once with both updates applied. This is why you can call multiple setters in a row without worrying about performance.

Before React 18, batching only worked inside React event handlers. If you called setters inside a setTimeout or a fetch callback, each one triggered a separate render. React 18's automatic batching fixed this:

// React 18+ — batched even in async callbacks
setTimeout(() => {
setCount(1);
setFlag(true);
// Only one re-render, not two
}, 1000);

Object.is bail-out

When you call a setter, React compares the new value with the current one using Object.is. If they're the same, React skips the re-render entirely:

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

setCount(0); // Same value — React bails out, no re-render

This is an optimization, but it also explains why mutating objects doesn't trigger updates:

const [user, setUser] = useState({ name: 'Alice' });

// Bad — same reference, React bails out
user.name = 'Bob';
setUser(user); // Object.is(user, user) is true — no re-render

// Good — new reference, React sees a change
setUser({ ...user, name: 'Bob' }); // Different object — re-render

React doesn't deep-compare objects. It checks reference identity. If you pass the same object reference, React assumes nothing changed. Always create a new object or array when updating state.

A practical gotcha: stale state in async code

The closure behavior shows up in real-world code with timers and async operations:

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

const startTimer = () => {
setTimeout(() => {
setCount(count + 1); // count is 0 when this closure was created
}, 3000);
};

return (
<div>
<p>{count}</p>
<button onClick={startTimer}>Start (3s delay)</button>
</div>
);
}

If you click the button, then quickly click elsewhere to increment count through some other interaction, the timer's callback still uses count = 0 from when the closure was created. The fix is the functional updater:

setTimeout(() => {
setCount((prev) => prev + 1); // Always uses the latest value
}, 3000);

The functional form doesn't rely on the closure's snapshot. It asks React: "Whatever the current value is right now, add 1 to it." This makes it immune to stale closures.

Why interviewers ask this

This question gets to the heart of React's rendering model. Interviewers want to know if you understand that state updates are asynchronous (enqueued, not immediate), that each render has its own closure over state, and that batching exists. The stale closure problem comes up constantly in real code, and the functional updater is the standard fix. If you can explain the mental model — each render is a snapshot, setters schedule a new snapshot — you're demonstrating a deep understanding of how React actually works.