useState Deep Dive
What you'll learn
- When the initial value is actually read (hint: once)
- Lazy initializers for expensive first values
- The
Object.isbailout: whensetStateskips the re-render entirely - Why mutating objects and arrays is the #1 beginner bug, and the replacement patterns that fix it
- Choosing between plain and functional updates
- Stale state in async code, and the two ways out
useState looks like the simplest hook in the box. But it hides four sharp edges that account for a huge fraction of real-world React bugs: initialization timing, the bailout, reference equality, and stale closures. Let's take them one at a time, so none of them ever costs you an afternoon again.
The initial value is read once
import { useState } from 'react';
function Counter({ start }) {
const [count, setCount] = useState(start);
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
);
}
What happens:
- Mount: the cell is created, and
startis stored as its value. - The parent re-renders with a different
start… andcountdoesn't change. The cell already exists, so the argument is evaluated and ignored, remember the cursor walk from the hooks chapter. useState(start)is not a binding. It's a one-time seed.
If you genuinely want state to reset when a prop changes, the idiomatic tool is the key prop (<Counter key={start} start={start} />), which creates a fresh instance with a fresh list, a later chapter goes deep on key.
Lazy initializers for expensive first values
Because the argument is evaluated on every render even though it's only used once, an expensive initial computation needs a function wrapper:
import { useState } from 'react';
function buildHugeIndex() {
console.log('expensive: building index...');
const index = {};
for (let i = 0; i < 100000; i++) {
index['key' + i] = i;
}
return index;
}
function SearchBox() {
const [index] = useState(() => buildHugeIndex());
const [query, setQuery] = useState('');
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<p>Index size: {Object.keys(index).length}</p>
</div>
);
}
What happens:
- Mount: React sees a function argument, calls it once, and stores the result in the cell. The log prints once.
- Every re-render (each keystroke): the initializer is not called again. Typing stays fast.
- Compare with
useState(buildHugeIndex()): that calls the function on every render to produce an argument React then throws away, 100,000 wasted loop iterations per keystroke.
Rule of thumb: cheap initial value → pass the value. Expensive computation → pass a function.
The Object.is bailout: setting state to the same value
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
console.log('RENDER', count);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(0)}>Set to 0 again</button>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
What happens:
- Click "Set to 0 again" while
countis already0: React compares the requested value with the current one usingObject.is(0, 0)→ identical. - React bails out: no re-render, no
RENDERlog, no DOM work. - Click "+1" then "Set to 0 again": the value differs, a render happens, and now the state is
0again.
Jargon: "bailout". React skipping work because it can prove nothing would change. For
useState, the proof isObject.is(currentValue, nextValue).
One nuance worth knowing: after the first identical set, React may still render the component one extra time before bailing out, an internal detail of when the comparison happens. Don't memorize render counts. The practical rule is: same value → no visible change; never write code that depends on exact render counts.
Mutating instead of replacing: the #1 beginner bug
import { useState } from 'react';
function ProfileEditor() {
const [person, setPerson] = useState({ name: 'Ada', age: 36 });
function handleRename() {
person.name = 'Grace'; // mutates the SAME object
setPerson(person); // passes the SAME reference
}
return (
<div>
<p>{person.name}, {person.age}</p>
<button onClick={handleRename}>Rename</button>
</div>
);
}
What happens:
person.name = 'Grace'changes a property inside the object, but the object's identity, its reference, is unchanged.setPerson(person)compares:Object.is(oldPerson, newPerson)→true, same reference.- Bailout. No render. The screen still says "Ada" while the underlying object says "Grace", the worst kind of bug, invisible and confusing.
The fix is to replace, not mutate:
function handleRename() {
setPerson({ ...person, name: 'Grace' }); // a NEW object
}
What happens: a fresh object with a fresh reference reaches the setter; Object.is says different; React renders; the screen says "Grace". Immutability isn't a style preference, it's how React sees change.
Replacement patterns cheat sheet
Objects and nested objects:
setPerson({ ...person, age: 37 });
setPerson({
...person,
address: { ...person.address, city: 'Paris' }, // spread every level you touch
});
Arrays, a complete working example with the three operations you'll write daily:
import { useState } from 'react';
let nextId = 3;
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 0, text: 'Learn elements' },
{ id: 1, text: 'Learn state' },
{ id: 2, text: 'Learn effects' },
]);
function addTodo(text) {
setTodos([...todos, { id: nextId++, text }]); // add: new array via spread
}
function removeTodo(id) {
setTodos(todos.filter(t => t.id !== id)); // remove: filter returns new array
}
function renameTodo(id, text) {
setTodos(todos.map(t => // replace one: map returns new array
t.id === id ? { ...t, text } : t
));
}
return (
<div>
<ul>
{todos.map(t => (
<li key={t.id}>
{t.text}
<button onClick={() => renameTodo(t.id, t.text + '!')}>!</button>
<button onClick={() => removeTodo(t.id)}>x</button>
</li>
))}
</ul>
<button onClick={() => addTodo('New todo')}>Add</button>
</div>
);
}
What happens: every operation builds a new array (spread, filter, map) containing new-or-reused items, a new reference → render. The mutating twins (push, splice, arr[i] = x) keep the old reference → the bailout bug.
Functional updates when next depends on previous
From the request chapter: when the next state is computed from the previous, give the setter an updater. The classic case is a timer whose closure froze count at mount:
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // reads the latest value at processing time
}, 1000);
return () => clearInterval(id);
}, []);
What happens: with setCount(count + 1) and empty deps, the interval's closure would see the mount render's count forever, and the counter would stick at 1. The updater c => c + 1 runs at queue-processing time with the freshest value, so it ticks correctly forever. (The next chapter builds this timer properly.)
Stale state in async code: and the escape routes
import { useState } from 'react';
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function ChatLog() {
const [log, setLog] = useState([]);
async function handleClick() {
await delay(1000);
setLog([...log, 'clicked']); // `log` is this render's snapshot!
}
return (
<div>
<button onClick={handleClick}>Add after 1s</button>
<p>{log.length} entries</p>
</div>
);
}
What happens:
- Click three times quickly. Each click's callback closes over the same render's
log, an empty array. - One second later, all three callbacks run
setLog([...[], 'clicked']), three identical one-entry arrays. - The screen shows
1 entries, not 3. Each write overwrote the others with stale data.
The fix is a functional update, which computes from the latest value at processing time:
async function handleClick() {
await delay(1000);
setLog(current => [...current, 'clicked']);
}
What happens: each updater receives the freshest log and appends → three entries, as expected.
The second escape route, for when you need to read the latest value without setting anything: mirror the state into a ref (ref.current = value during render) and read ref.current in async code. The refs chapter covers that pattern in full.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception:
useState(initial)re-appliesinitialwhen the prop behind it changes. Reality: the argument is read at mount only; later renders ignore it. - Misconception: A lazy initializer runs whenever state is read. Reality: it runs once, at mount, and its result lives in the cell.
- Misconception:
setStatealways causes a re-render. Reality: anObject.is-identical value bails out, practically: no visible change, and don't count renders. - Misconception: Mutating an object and then calling the setter "notifies" React. Reality: identity comparison can't see inside objects; mutation is invisible.
- Misconception: Spread copies deeply. Reality:
{ ...obj }is shallow, every nested level you change needs its own spread. - Misconception: Async callbacks see the "current" state. Reality: they see their own render's snapshot; functional updates compute from the latest value instead.
Why it works this way
- Identity comparison is O(1). React cannot deep-diff arbitrary state on every set; reference equality is the contract, and immutability is your side of the deal.
- Mount-only initialization keeps renders predictable. "Reset" is expressed structurally (
key, new instance) instead of by watching props. - The bailout is a free optimization that enforces the contract. State you replace is state React can verify.
- Functional updates run at the only moment the latest value exists, processing time, so correctness never depends on which render's closure happened to fire.
Try it yourself
- Add the
RENDERlog to the bailout example. Click "Set to 0 again" at 0 and count renders; click "+1" and compare. - Build the mutating
ProfileEditor, watch it silently fail, then fix it with spread and watch it work. Say the rule out loud: replace, don't mutate. - In
TodoApp, swapfilterforspliceand observe the frozen UI, then explain exactly which comparison failed. - Run the
ChatLogexperiment: three quick clicks produce 1 entry with the plain version, 3 with the functional version. Verify both.
Recap
- The initial value is read at mount and ignored afterwards; expensive initial work goes in a lazy initializer:
useState(() => compute()). setStatewith anObject.is-identical value bails out, same value, no visible change.- Objects and arrays must be replaced, not mutated, mutation keeps the reference and becomes invisible to React.
- Patterns:
{ ...obj, field }, nested spreads per level, array add via spread, remove viafilter, replace viamap. - When next depends on previous, or the write happens in async code, use a functional update; it runs at processing time with the latest value.
- For reading the latest value in async code without setting anything, the escape hatch is a ref mirror.