Difficulty: Beginner
How does useState work? Explain state batching, functional updates, and immutable state patterns.
useState looks deceptively simple - you call it, you get a value and a setter, done. But the devil is in the details, and understanding how state updates actually work under the hood will save you from some of the most confusing bugs in React.
Let's start with the basics. When you call useState(initialValue), React creates a "slot" for that piece of state internally. It returns a pair: the current value, and a setter function that lets you update it. When you call the setter, React doesn't update the value immediately and re-render right there on the spot. Instead, it schedules a re-render. This means if you call setCount(5) and then immediately log count, you'll see the old value. The new value is only available on the next render. This is one of the most common sources of confusion for React beginners.
A quick tip on initialization: if your initial state requires an expensive computation (like parsing a large JSON string or computing something from props), pass a function to useState instead of a value: useState(() => expensiveComputation()). This is called lazy initialization. The function runs only on the very first render - on subsequent renders, React ignores the initializer entirely and uses the stored state. If you pass the value directly, the expensive computation runs on every render even though its result is thrown away after the first one.
Now let's talk about state batching, because this changed significantly in React 18 and it comes up in interviews a lot.
Batching means grouping multiple state updates into a single re-render. If you have a click handler that calls setCount(1), setFlag(true), and setName('Alice'), you don't want three separate re-renders - you want one. In React 17 and earlier, batching only happened inside React event handlers (onClick, onChange, etc.). State updates inside promises, setTimeout, fetch callbacks, or native event listeners were NOT batched - each setState triggered its own re-render. This was a performance gotcha that caught many developers off guard.
React 18 introduced automatic batching everywhere. Now, no matter where your state updates happen - inside event handlers, inside promises, inside setTimeout, inside native event listeners - React batches them all into a single re-render. This is a significant performance improvement that happened automatically when you upgraded to React 18. The rare case where you actually need each update to trigger its own immediate re-render, you can use flushSync from react-dom. But this is extremely uncommon.
Functional updates are another crucial concept. When your new state depends on the previous state, you should always pass a function to the setter: setCount(prev => prev + 1) instead of setCount(count + 1). Here's why this matters so much.
Imagine you write a function that calls setCount(count + 1) three times in a row, expecting count to go from 0 to 3. It won't. Because all three calls execute synchronously before any re-render happens, they all see the same value of count (let's say 0). So you get setCount(0 + 1), setCount(0 + 1), setCount(0 + 1) - the result is 1, not 3. With functional updates, each call receives the most recent pending state: setCount(prev => prev + 1) gets 0, the next gets 1, the next gets 2, result is 3. The functional form is also essential inside closures that might go stale - like intervals or timeouts - because the updater function always gets the latest value regardless of when the closure was created.
Now, immutability. This is fundamental to React's design and probably the source of the most bug reports from developers learning React.
React determines whether to re-render by comparing the old state to the new state using Object.is (which behaves like === for objects). If you mutate an object or array in place and then call setState with the same reference, React looks at the state, sees the same reference as before, and says "nothing changed, skip the re-render." Your data changed, but the UI doesn't update. This is maddening if you don't understand why it's happening.
So you must always create new references when updating state. For objects, use the spread operator: setState(prev => ({ ...prev, name: 'Alice' })). For arrays, use methods that return new arrays: map (to transform items), filter (to remove items), and spread with concat (to add items). Never use push, pop, splice, or direct index assignment on state arrays - these mutate the existing array.
Here are the common patterns: - Adding to an array: setItems(prev => [...prev, newItem]) - Removing from an array: setItems(prev => prev.filter(item => item.id !== targetId)) - Updating an item in an array: setItems(prev => prev.map(item => item.id === targetId ? { ...item, done: true } : item)) - Updating a nested object: setState(prev => ({ ...prev, address: { ...prev.address, city: 'NYC' } }))
For deeply nested state, the spread-based immutable updates get really verbose and hard to read. At that point, consider using a library like Immer (which lets you write "mutative" code that produces immutable updates under the hood) or restructuring your state to be flatter.
One final thing: React uses Object.is for comparison, which means it has some edge cases you should know. NaN is equal to NaN (unlike ===), and +0 is not equal to -0. In practice, these rarely matter, but it's good to know.
function BatchingExample() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
console.log('Rendered!'); // How many times?
// React 18: ALL updates are batched (one re-render)
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// Only ONE re-render happens here
}
// Even in async code - React 18 batches these too!
async function handleAsync() {
const data = await fetch('/api/data');
setCount(c => c + 1);
setFlag(f => !f);
// Still only ONE re-render in React 18
// In React 17, this would cause TWO re-renders
}
// To opt OUT of batching (rare):
import { flushSync } from 'react-dom';
function handleUrgent() {
flushSync(() => setCount(c => c + 1));
// DOM is updated here
flushSync(() => setFlag(f => !f));
// DOM is updated here
}
return <button onClick={handleClick}>{count}</button>;
}
React 18 introduced automatic batching for all updates. In React 17, only event handler updates were batched. Use flushSync to force immediate updates when needed.
function Counter() {
const [count, setCount] = useState(0);
// BUG: All three use the SAME stale value of count
function handleBrokenTripleIncrement() {
setCount(count + 1); // 0 + 1 = 1
setCount(count + 1); // 0 + 1 = 1 (count is still 0!)
setCount(count + 1); // 0 + 1 = 1
// Result: count = 1, NOT 3
}
// CORRECT: Each update uses the latest pending state
function handleTripleIncrement() {
setCount(prev => prev + 1); // 0 + 1 = 1
setCount(prev => prev + 1); // 1 + 1 = 2
setCount(prev => prev + 1); // 2 + 1 = 3
// Result: count = 3
}
return (
<div>
<p>Count: {count}</p>
<button onClick={handleTripleIncrement}>+3</button>
</div>
);
}
When new state depends on old state, always use functional updates. The updater function receives the most recent pending state value, not the stale closure value.
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', done: false },
{ id: 2, text: 'Build project', done: false },
]);
// BAD: Mutating state directly
function brokenToggle(id) {
const todo = todos.find(t => t.id === id);
todo.done = !todo.done; // Mutation!
setTodos(todos); // Same reference - React won't re-render!
}
// GOOD: Create new array with new object
function toggle(id) {
setTodos(prev => prev.map(todo =>
todo.id === id
? { ...todo, done: !todo.done }
: todo
));
}
// Add item (new array with new item)
function addTodo(text) {
setTodos(prev => [
...prev,
{ id: Date.now(), text, done: false }
]);
}
// Remove item (filter creates new array)
function removeTodo(id) {
setTodos(prev => prev.filter(t => t.id !== id));
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id} onClick={() => toggle(todo.id)}>
{todo.done ? '✓' : '○'} {todo.text}
</li>
))}
</ul>
);
}
Always return new references when updating state. Use spread operator for objects, map/filter for arrays. Never mutate in place - React compares references to detect changes.
useState, State Batching, Functional Updates, Immutability