useState Interview Questions & Answers

34 questions Updated 2026-06-17

React useState interview questions — state updates, batching, functional updates, lazy initialization and why state seems one render behind.

Read the in-depth guideReact useState Hook — A Complete Guide with Examples

useState returns an array of exactly two elements: the current state value for this render, and a setter function that schedules an update and triggers a re-render. You destructure them, and the [value, setValue] naming convention is just that — a convention, not something React enforces.

const [count, setCount] = useState(0)
//     ▲ current value  ▲ setter that re-renders with the next value

A few things that trip people up in interviews:

  • The argument (0 here) is only the initial value. On every render after the first, React ignores it and hands you the latest stored value.
  • The setter has a stable identity — React guarantees it never changes between renders, so it's safe to omit from useEffect/useCallback dependency arrays.
  • Calling the setter does not mutate count in the current scope; it asks React to render again with a new value.

Because count is a const captured by this render's closure, not a live reference to a mutable box. Each render gets its own count constant frozen at the value it had when that render ran. Calling setCount schedules a future render with a new value — it cannot reach back and change 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
}

So state isn't really "behind" — you're reading a snapshot. The new value becomes visible only in the next render's function body. If you need the updated value immediately after setting it, derive it locally (const next = count + 1) or read it in an effect that runs after the re-render.

Use the functional form — setCount(c => c + 1) — whenever the next state is derived from the previous state, especially when several updates happen in one event or across async boundaries. React passes the most up-to-date value into your updater, so you never base a calculation on 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)

Rule of thumb: if the new value mentions the old value, prefer the function form. If you're setting an unrelated value (setCount(0)), the direct form is fine.

If your initial value is expensive to compute, pass a function to useState instead of the value itself. React calls that function only on the first render and ignores it afterwards. Passing the value directly would re-run the expensive computation on every render and then throw the result away.

// readFromLocalStorage() runs on every single render
const [items, setItems] = useState(readFromLocalStorage())

// runs once, on mount only
const [items, setItems] = useState(() => readFromLocalStorage())

Watch the distinction: useState(expensive()) calls expensive every render (its return value is the argument), whereas useState(expensive) / useState(() => expensive()) hands React the function to call once.

Unlike this.setState in class components, the useState setter replaces the value — it does not shallow-merge. To change one field of an object you must spread the previous object yourself and override the field, returning a new object (mutating the existing one won't trigger a re-render because the reference is unchanged).

const [user, setUser] = useState({ name: 'Ada', age: 36 })

// new object, old fields preserved, name overridden
setUser(u => ({ ...u, name: 'Grace' }))

// mutates in place — same reference, React skips the re-render
user.name = 'Grace'
setUser(user)

For deeply nested state this spreading gets verbose; that's often the signal to reach for useReducer or a state library.

Batching is React grouping multiple state updates into a single re-render for performance, instead of re-rendering once per setState call. If you call three setters in one click handler, React processes them together and renders once.

function handleClick() {
  setA(1)
  setB(2)
  setC(3)
  // ONE re-render, not three
}

Before React 18, batching only happened inside React event handlers; updates in setTimeout, promises, or native event listeners each caused their own render. React 18's automatic batching extends grouping to those async contexts too. If you ever need to opt out and force a synchronous, separate render, wrap the update in flushSync from react-dom.

Usually no. If a value can be calculated from existing props or state, derive it during render instead of duplicating it in useState — extra state can drift out of sync and forces you to keep two things updated.

// redundant state that can desync
const [items, setItems] = useState([])
const [count, setCount] = useState(0)

// derive it
const [items, setItems] = useState([])
const count = items.length

Only store something in state if it's genuinely independent input. "You might not need state" is the React team's own guidance.

When two components need to share or stay in sync over the same data, you move the state to their closest common parent and pass it down as props 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} />
    </>
  )
}

It's the standard fix for "these siblings need the same data." When lifting gets painful across many levels, that's the signal to reach for Context or a store.

Use useState for values that should trigger a re-render when they change. Use useRef for mutable values that should persist across renders but NOT cause re-renders — timer ids, previous values, DOM nodes.

const [count, setCount] = useState(0) // UI depends on it -> re-render
const renders = useRef(0)             // bookkeeping -> no re-render
renders.current++

Changing ref.current is invisible to React's render cycle. If the screen needs to reflect the value, it belongs in state; otherwise a ref avoids needless renders.

The cleanest way is to change the component's key. React treats a new key as a brand-new component, unmounting the old instance (discarding its state) and mounting a fresh one — no manual reset code.

<Profile key={userId} userId={userId} />
// when userId changes, Profile remounts with fresh state

Alternatives are manually calling setters back to initial values, but key is idiomatic for "start this subtree over." Avoid resetting state inside an effect by watching a prop — the key approach is simpler and bug-free.

Because the useState setter treats a function argument as an updater, you must wrap a function you want to store in another function — otherwise React calls it instead of saving it.

// React invokes handleClick to compute the next state
const [fn, setFn] = useState(handleClick)
setFn(handleClick)

// wrap it so it's stored, not called
const [fn, setFn] = useState(() => handleClick)
setFn(() => handleClick)

The same rule applies to lazy initialization. Storing functions in state is rare — usually a ref or just defining the function in render is cleaner.

Treat the array as immutable — never push/splice the existing array (same reference -> no re-render). Build a new array with spread or filter.

// add
setItems(prev => [...prev, newItem])
// remove by id
setItems(prev => prev.filter(it => it.id !== id))
// insert at index
setItems(prev => [...prev.slice(0, i), newItem, ...prev.slice(i)])

Using the functional updater (prev =>) keeps you correct when several updates batch together.

Map over the array, returning a new object for the matching item and the originals for the rest. Mutating the found object in place won't re-render and can corrupt previous renders.

setUsers(prev =>
  prev.map(u => u.id === id ? { ...u, name: 'Ada' } : u)
)

The rule: new array and new object for whatever you change. For deeply nested updates this gets verbose — a signal to use useReducer or Immer.

Reach for useReducer when state is complex (multiple sub-values that change together), when the next state depends on intricate logic, or when you want to centralize update logic in one tested function instead of scattering setters.

const [state, dispatch] = useReducer(reducer, initial)
dispatch({ type: 'increment', by: 2 })

useState is best for simple, independent values. A heuristic: if you find yourself calling several setters together or your setter logic is branchy, a reducer makes intent clearer and easier to test.

A controlled input gets its value from state and updates state on every keystroke via onChange, making React the single source of truth.

const [text, setText] = useState('')
<input value={text} onChange={e => setText(e.target.value)} />

Forgetting onChange while setting value makes the field read-only (React warns). The benefit is you can validate, format, or react to every change; the cost is a re-render per keystroke (rarely a problem).

  • Controlled — React state holds the value (value + onChange). Predictable and easy to validate, but re-renders on each change.
  • Uncontrolled — the DOM holds the value; you read it via a ref when needed (defaultValue for the initial value).
// uncontrolled
const ref = useRef()
<input defaultValue="hi" ref={ref} />
// read ref.current.value on submit

Controlled is the default recommendation; uncontrolled suits simple forms, file inputs, or integrating non-React widgets.

Prefer multiple useState calls for values that change independently — it's simpler and you don't have to spread-merge on every update. Group into one object only when fields genuinely change together.

// independent values
const [name, setName] = useState('')
const [age, setAge] = useState(0)

// grouping requires manual merge (no auto-merge like class setState)
setForm(prev => ({ ...prev, name: 'Ada' }))

Remember the useState setter replaces, it doesn't merge — so an object of state means spreading the rest every time.

Spread at every level you change, all the way down — only the touched branches get new references.

setUser(prev => ({
  ...prev,
  address: { ...prev.address, city: 'Paris' },
}))

This is error-prone and verbose for deep trees. Options: restructure to flatter state, switch to useReducer, or use Immer (produce) which lets you "mutate" a draft and produces the immutable update for you.

Redundant state is data you keep in useState that's already derivable from other state or props. It invites bugs because you must remember to update it everywhere, and the copies can disagree.

// fullName must be kept in sync manually
const [fullName, setFullName] = useState('')
// derive it
const fullName = `${first} ${last}`

Keep state minimal and orthogonal — the smallest set of independent values from which everything else is computed during render.

Use the functional updater so you always flip the latest value, which matters if toggles can batch.

const [open, setOpen] = useState(false)
const toggle = () => setOpen(o => !o)

Avoid setOpen(!open) in code paths that may run multiple times in one event — it reads a possibly stale open. The functional form is always safe.

A callback captures the state value from the render it was created in, so after an await it may be stale. To act on the latest value, use the functional updater (which receives the current value) or store it in a ref.

async function save() {
  await delay(1000)
  // `count` is whatever it was when save() was created
  // read the latest via the updater
  setCount(latest => { send(latest); return latest })
}

Cleaner still: pass the needed value as an argument, or keep a useRef mirror of the state for "read latest" access without triggering renders.

If you set state to a value that's Object.is-equal to the current one, React bails out and skips re-rendering that component (it may still re-run the component once to check, then stop).

const [n, setN] = useState(0)
setN(0) // same value -> React bails out, no committed re-render

The catch: this is reference equality. setItems([]) with a new empty array is a different reference, so it does re-render even though the contents look identical. Don't create fresh objects/arrays for "no change."

  • State is data a component owns and can change over time via its setter.
  • Props are data passed in from a parent; the child treats them as read-only.
function Counter({ step }) {        // step is a prop (read-only)
  const [count, setCount] = useState(0) // count is state (owned)
  return <button onClick={() => setCount(c => c + step)}>{count}</button>
}

A child never mutates props; to change parent data it calls a callback prop. One component's state is often another's props (passed down).

React identifies each hook by its call order, not a name. Calling useState inside a condition, loop, or after an early return changes that order between renders, so React mismatches state to the wrong hook.

// breaks hook ordering
if (loggedIn) {
  const [name, setName] = useState('')
}
// always call unconditionally; branch on the value
const [name, setName] = useState('')
if (loggedIn) { /* use name */ }

Always call hooks at the top level of the component, in the same order every render — the eslint-plugin-react-hooks rule enforces this.

Two common cases: (1) you mutated the existing object/array and passed the same reference, so React sees no change; or (2) you set a primitive to the same value (React bails out via Object.is).

// mutate + same reference -> no re-render
user.name = 'Ada'
setUser(user)
// new reference
setUser({ ...user, name: 'Ada' })

The fix is always to produce a new reference for changed objects/arrays. If the UI "isn't updating," this mutation trap is the first thing to check.

Generally you should not — setting state in the render body unconditionally causes an infinite loop. React does support a narrow pattern: calling a setter conditionally during render to adjust state based on a prop change, which it handles without an extra paint.

// rare, allowed: derive on prop change without an effect
const [prevId, setPrevId] = useState(id)
if (id !== prevId) {
  setPrevId(id)
  setSelection(null) // reset when id changes, no effect needed
}

Most of the time you don't need this — prefer deriving values or the key reset trick. Never call a setter unconditionally in render.

Calling a setter unconditionally during render, or inside an effect whose dependencies you keep changing, makes React render -> set -> render forever.

// sets state every render -> infinite loop
const [n, setN] = useState(0)
setN(n + 1)

// effect with a new object dep each render
useEffect(() => setData(load()), [{}])

Fixes: only set state in event handlers or effects with stable deps, derive values instead of storing them, and avoid fresh object/array literals in dependency arrays.

The useState initializer is only read on the first render. Passing a prop as the initial value captures it once; later changes to the prop don't flow into the state.

function Field({ initial }) {
  const [value, setValue] = useState(initial) // only the first `initial` is used
  // later `initial` changes are ignored
}

If you truly need to reset when the prop changes, use the key prop to remount, or the conditional set-during-render pattern. Often the better answer is to not copy the prop into state at all.

Use useMemo — it recomputes the value only when its dependencies change, so you get the benefit of a cached derivation without the desync risk of putting it in state.

const sorted = useMemo(
  () => [...items].sort(compare),
  [items]
)

This keeps items as the single source of truth while avoiding re-sorting on every keystroke. Don't reach for useMemo until the computation is actually expensive — needless memoization adds its own overhead.

Keep state as close as possible to where it's used (colocation), and lift it only as high as the nearest common ancestor that needs to share it. Over-lifting state to the top causes unnecessary re-renders and prop drilling.

// a modal's "open" state belongs in the component that owns the modal,
// not in the app root
function Toolbar() {
  const [open, setOpen] = useState(false)
  // ...
}

Colocated state means fewer renders and simpler components; global/app state should be reserved for genuinely shared data.

Hold the fields in one object and use a generic change handler keyed by the input's name, spreading the previous object to preserve the other fields.

const [form, setForm] = useState({ name: '', email: '' })
const onChange = e =>
  setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))

<input name="email" value={form.email} onChange={onChange} />

The computed key [e.target.name] updates just one field. For large/validated forms, a form library or useReducer scales better than hand-rolling this.

No. The class this.setState(value, callback) second argument doesn't exist for the useState setter. To run code after a state update commits, use a useEffect that depends on that state.

const [count, setCount] = useState(0)
useEffect(() => {
  // runs after the render caused by count changing
  analytics.track(count)
}, [count])

This is the hooks way to "do X after state changes" — react to the new value in an effect rather than passing a callback to the setter.

An <input>'s value is always a string, so convert it when you need a number, and decide how to handle empty/invalid input.

const [age, setAge] = useState('')
<input
  type="number"
  value={age}
  onChange={e => setAge(e.target.value)} // keep the raw string in state
/>
const ageNum = age === '' ? 0 : Number(age) // parse where you use it

Storing the raw string avoids fighting the input (e.g. a partially typed - or .), and you parse to a number only at the point of use.

const [count, setCount] = useState(0)
function handle() {
  setCount(count + 1)
  setCount(count + 1)
  setCount(count + 1)
} // after one click, count is 1 — not 3

All three read the same count (0) from this render's closure, each computing 1, and batching collapses them to a single update of 1. Use the functional form to actually add 3:

setCount(c => c + 1) // ×3 -> each receives the previous result -> 3

This puzzle tests whether you understand closures-over-state plus batching.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.