React · Hooks

React useState Hook — A Complete Guide with Examples

10 min read Updated 2026-06-17

Practice useState interview questions

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 useState for values that should trigger a re-render when they change.
  • Use useRef for mutable values that persist across renders but should not cause a render — timer ids, the previous value, a DOM node. Changing ref.current is invisible to React's render cycle.
  • Use useReducer when 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 useState inside 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, so setItems([]) 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 a useEffect that 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.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.