A complete guide to useState
useState is the first hook every React developer learns, and also the one that
hides the most subtle behavior. On the surface it just stores a value and gives you
a way to change it. Underneath, it ties into how React renders, how JavaScript
closures capture values, and how updates are batched and scheduled. This guide
walks through all of it — from the basics to the edge cases interviewers love — so
that by the end you can reason confidently about any useState question.
The shape of the hook
useState returns an array of exactly two elements: the current value for this
render and a setter that schedules an update.
const [count, setCount] = useState(0)
// ▲ value for this render ▲ asks React to re-render with a new value
You destructure them into a [value, setValue] pair — a naming convention, not a
rule React enforces. The argument you pass (0) is only the initial value: it's
used on the very first render and ignored on every render afterward, when React
hands you the latest stored value instead.
Two facts about the setter are worth committing to memory. First, its identity is
stable — React guarantees the same setter function across renders, so it's safe to
leave out of useEffect or useCallback dependency arrays. Second, calling it does
not change the value variable in your current scope; it schedules a future
render.
State is a snapshot, not a live variable
This is the single most important idea in useState. Each render gets its owncount constant, frozen at the value it had when that render ran. The setter can't
reach back and mutate the count you're currently looking at.
function handleClick() {
console.log(count) // e.g. 0
setCount(count + 1) // schedules a render where count will be 1
console.log(count) // STILL 0 — same render, same frozen constant
}
People describe this as state being "one render behind," but that framing is
misleading. You're not behind — you're reading a snapshot. The new value only
becomes visible in the next render's function body. If you need the updated value
right away inside the same handler, compute it locally (const next = count + 1) and
use next.
This snapshot model is a direct consequence of JavaScript closures: every function defined during a render closes over that render's variables. It explains stale values in timers, effects, and async callbacks — all of which capture the value from the render in which they were created.
Updating state correctly
Functional updates
Whenever the next state depends on the previous state, pass a function to the setter instead of a value. React calls your updater with the most up-to-date state, so you never compute from a stale snapshot.
// all three read the same stale `count`, so this adds 1, not 3
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
// each updater receives the result of the previous one -> +3
setCount(c => c + 1)
setCount(c => c + 1)
setCount(c => c + 1)
The rule of thumb: if the new value mentions the old value, use the function form.
For an unrelated value (setCount(0)), the direct form is fine. The functional form
is also what keeps toggles and async handlers correct:
const toggle = () => setOpen(o => !o) // always flips the latest value
async function save() {
await delay(1000)
setCount(latest => { send(latest); return latest }) // read the freshest value
}
Batching
React groups multiple state updates that happen in the same event into a single re-render for performance. Three setters in one click handler produce one render, not three.
function handleClick() {
setA(1)
setB(2)
setC(3)
// ONE re-render
}
Before React 18, batching only applied inside React event handlers; updates in
setTimeout, promises, or native listeners each caused their own render. React 18
automatic batching extends grouping to those async contexts too. On the rare
occasion you need a synchronous, separate render (for example, to measure the DOM
between two updates), wrap an update in flushSync from react-dom.
Lazy initialization
If computing the initial value is expensive, pass a function to useState.
React calls it only on the first render and ignores it afterward.
// readFromStorage() runs on every render, then the result is thrown away
const [items, setItems] = useState(readFromStorage())
// runs once, on mount only
const [items, setItems] = useState(() => readFromStorage())
Watch the distinction carefully: useState(expensive()) calls expensive every
render (its return value is the argument), while useState(() => expensive()) hands
React a function to call once. The same wrapping trick is how you store a function
in state — wrap it so the setter saves it instead of treating it as an updater:
useState(() => myFn).
Working with objects and arrays
The useState setter replaces the value — unlike the old class this.setState,
it does not shallow-merge. And React decides whether to re-render by comparing
references, so you must always produce a new object or array; mutating the
existing one in place won't trigger a render.
// new object, old fields preserved
setUser(u => ({ ...u, name: 'Grace' }))
// same reference — React skips the re-render
user.name = 'Grace'
setUser(user)
Arrays follow the same principle — never push/splice; build a new array:
setItems(prev => [...prev, newItem]) // add
setItems(prev => prev.filter(it => it.id !== id)) // remove
setItems(prev => prev.map(it => // update one item
it.id === id ? { ...it, done: true } : it))
For deeply nested state you must spread at every level you change, which gets verbose:
setUser(prev => ({ ...prev, address: { ...prev.address, city: 'Paris' } }))
When this spreading becomes painful, that's the signal to flatten your state, reach
for useReducer, or use a library like Immer. The same pattern powers a generic
form handler, where a computed key updates exactly one field:
const [form, setForm] = useState({ name: '', email: '' })
const onChange = e =>
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
Choose the minimum state
A common source of bugs is storing data that you could derive. If a value can be
computed from existing state or props, compute it during render instead of keeping a
second copy in useState that can drift out of sync.
// redundant — must be kept in sync by hand
const [items, setItems] = useState([])
const [count, setCount] = useState(0)
// derive it
const [items, setItems] = useState([])
const count = items.length
If the derivation is genuinely expensive, cache it with useMemo rather than storing
it in state — you keep a single source of truth while avoiding recomputation:
const sorted = useMemo(() => [...items].sort(compare), [items])
Keep state minimal, orthogonal, and colocated: the smallest set of independent
values, placed as close as possible to where they're used. Prefer several
independent useState calls over one big object when the fields change separately —
you avoid the spread-merge on every update.
useState vs useRef vs useReducer
- Use
useStatefor values that should trigger a re-render when they change. - Use
useReffor mutable values that persist across renders but should not cause a render — timer ids, the previous value, a DOM node. Changingref.currentis invisible to React's render cycle. - Use
useReducerwhen state is complex, several values change together, or you want to centralize update logic in one tested function instead of scattering setters.
const renders = useRef(0) // bookkeeping, no re-render
renders.current++
const [state, dispatch] = useReducer(reducer, initial)
dispatch({ type: 'increment', by: 2 })
A quick heuristic: if the UI depends on it, it's state; if you keep calling several setters together, it's probably a reducer.
Sharing and resetting state
When two components need the same data, lift state up to their closest common parent and pass it down as a prop plus a setter callback. The parent becomes the single source of truth.
function Parent() {
const [value, setValue] = useState('')
return (
<>
<Input value={value} onChange={setValue} />
<Preview value={value} />
</>
)
}
To reset a component's state to its initial values, the cleanest trick is to
change its key. React treats a new key as a brand-new component, throwing away the
old state and mounting fresh — no manual reset code:
<Profile key={userId} userId={userId} />
This also explains a classic gotcha: initializing state from a prop captures that
prop only on the first render, so later changes to the prop don't update the
state. If you need a reset when the prop changes, use key rather than copying the
prop into state.
Controlled inputs
A controlled input reads its value from state and updates state on every keystroke, making React the single source of truth.
const [text, setText] = useState('')
<input value={text} onChange={e => setText(e.target.value)} />
Setting value without onChange makes the field read-only (React warns). The
alternative is an uncontrolled input, where the DOM holds the value and you read
it via a ref when needed (defaultValue for the initial value) — handy for simple
forms and file inputs. For number inputs, remember the value is always a string;
keep the raw string in state and parse where you use it, so a half-typed - or .
doesn't fight the user.
Rules and gotchas
- Call hooks at the top level. React identifies each hook by call order, so never
put
useStateinside a condition, loop, or after an early return. Call it unconditionally and branch on the value. - Same value bails out. Setting state to a value that is
Object.is-equal to the current one makes React skip the committed re-render. But this is reference equality, sosetItems([])with a fresh empty array still re-renders. - Don't set state unconditionally during render — it causes an infinite loop. The same applies to setting state in an effect whose dependency changes as a result.
- There's no setter callback. The class
this.setState(value, callback)second argument doesn't exist. To run code after an update commits, use auseEffectthat depends on the value.
useEffect(() => {
analytics.track(count) // runs after the render caused by count changing
}, [count])
Recap
useState gives you a value and a setter, but the depth is in the model behind them.
State is a per-render snapshot captured by closures, which is why values look
"one render behind" and why stale closures happen. Updates are asynchronous and
batched, so use the functional form whenever the next value depends on the
previous one. Treat objects and arrays as immutable, producing new references so
React notices the change. Keep your state minimal — derive what you can, reach
for useRef/useReducer/useMemo when they fit better, lift state up to share it,
and change the key to reset it. Master those ideas and every useState question,
from lazy initialization to batching puzzles, becomes straightforward.