useState — The Core of Everything
BeginnerState is a snapshot for a given render, not a variable you read live. That one idea explains stale values, batching, and why you update with a function.
Overview
The common mental model is that setState assigns to a variable. It does not. Each render has its own frozen copy of state; calling the setter schedules a new render with a new value, and the current render keeps the old one until it is done. This is why reading state immediately after setting it gives the old value, why two increments in one handler can produce one increment, and why the functional updater exists. Once state is understood as a per-render snapshot, most beginner React bugs stop being mysterious.
State Is a Snapshot
The value does not change during a render, even after you set it.
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1)
console.log(count) // still 0 — this render's snapshot
}
// Two updates from the same snapshot collapse into one
function handleDouble() {
setCount(count + 1) // 0 + 1
setCount(count + 1) // 0 + 1 again -> ends at 1, not 2
}
// The functional updater reads the latest queued value
function handleDoubleFixed() {
setCount(c => c + 1) // 0 -> 1
setCount(c => c + 1) // 1 -> 2
}
// Rule of thumb: if the next value depends on the previous one,
// always use the function form.Batching and Initialisation
React groups updates within the same tick, and lazy initialisation avoids repeating expensive setup.
// All three cause ONE re-render (automatic batching, React 18+)
function submit() {
setLoading(false)
setData(rows)
setError(null)
}
// Batching applies inside promises, timeouts and native handlers too.
// Lazy initial state — the function runs only on the first render
const [rows, setRows] = useState(() => JSON.parse(localStorage.getItem('rows') ?? '[]'))
// Without the wrapper, JSON.parse runs on EVERY render and the
// result is thrown away every time except the first.
const [rows, setRows] = useState(JSON.parse(...)) // wasteful
// The initial value is used only on mount. Passing a changing prop
// as the initial value does not update it later — a frequent
// "why doesn't my component see the new prop" bug.
const [draft, setDraft] = useState(problem.title) // frozen at mountUpdating Objects and Arrays
Always produce a new value. Mutating in place changes the data without telling React.
// Objects
setUser({ ...user, name: 'Akshay' })
setUser(u => ({ ...u, prefs: { ...u.prefs, theme: 'dark' } })) // nested
// Arrays — use the methods that return a new array
setRows(r => [...r, newRow]) // add
setRows(r => r.filter(x => x.id !== id)) // remove
setRows(r => r.map(x => x.id === id ? { ...x, done: true } : x)) // update
setRows(r => [...r].sort(byDate)) // sort a COPY
// These mutate and must not be used on state directly:
// push, pop, splice, sort, reverse
rows.push(newRow); setRows(rows) // same reference -> no re-render
// Deeply nested state is a signal to flatten the shape or move to
// useReducer, not to write a five-level spread.Key Points to Remember
- 1State is a snapshot per render — reading it right after setting gives the previous value
- 2Use the functional updater whenever the next value depends on the previous one
- 3React batches updates in the same tick into a single re-render, including inside promises and timeouts
- 4Pass a function to useState for expensive initial values, and remember the initial value is only used on mount
- 5Never mutate state — push, splice and sort change the data without changing the reference React compares
Interview Questions
Sign in to ask AriaWhy does calling setCount(count + 1) twice in one handler only increment once?
What is the difference between useState(compute()) and useState(compute)?
Why does pushing to a state array and calling the setter not re-render?
Ask Aria about useState — The Core of Everything
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.