Beginner0 questionsFull Guide

useState Hook — Complete React Interview Guide

Master useState: snapshot model, functional updates, immutability, batching, lazy init, controlled inputs, and every edge case asked in React interviews.

The Mental Model

Picture useState as a sticky note that survives re-renders. Every time your component function runs — and React runs it a lot — all the local variables you declare get thrown away and recreated from scratch. The sticky note does not get thrown away. React keeps it safe on the side, hands it back to you at the top of your function, and puts your latest update on it after the next render. That is the entire mechanism. useState gives you a slot in React's memory that persists across function calls. Everything else — batching, functional updates, lazy initialisation — is a consequence of that one fact. The second thing to understand: calling setState does not change the variable right now. It schedules a re-render. During that re-render, React will call your component function again, and this time it hands back the new value from the sticky note. Until that re-render happens, the variable in the current render holds the old value. This is not a bug. It is a guarantee — a single render is always consistent. Every read of state within one render sees exactly the same snapshot. The third thing: React decides whether to re-render by comparing old and new state with Object.is. If you pass the same reference — same primitive value, same object reference — React bails out and skips the re-render entirely. This means mutating an object or array directly and then calling setState with the same reference is silent and catastrophic. You must always produce a new reference to trigger a re-render.

The Explanation

How useState works internally

React stores state in a linked list of "hooks" attached to each component instance (more precisely, to each fiber node). When your component function runs, React walks this linked list in order, returning one hook's value per useState call. This is why the Rules of Hooks exist — if you call useState conditionally or inside a loop, the list order changes between renders and React reads the wrong hook's value.

// React's internal model (simplified)
// Fiber node holds: [hook0, hook1, hook2, ...]
// Each hook: { state: currentValue, queue: [pending updates] }
 
function Counter() {
  const [count, setCount] = useState(0)   // hook0: { state: 0 }
  const [name, setName]   = useState('')  // hook1: { state: '' }
  // React reads hook0 first, hook1 second — every render, in this exact order
}

When you call setState, React enqueues an update to that hook's queue and schedules a re-render. On the next render, React processes the queue, computes the new state, and stores it in the hook before calling your function. Your function reads the new value via the hook slot.

The render snapshot — why state feels "stale" in event handlers

Each render is a snapshot in time. The state variables you read inside a render — including inside event handlers defined during that render — always hold the values from that specific render. They do not update when state changes.

function Counter() {
  const [count, setCount] = useState(0)
 
  function handleClick() {
    console.log(count)    // always logs the value from THIS render's snapshot
    setCount(count + 1)   // schedules count+1 for the NEXT render
    setCount(count + 1)   // still count+1, not count+2 — count didn't change yet
    setCount(count + 1)   // still count+1 — three calls, one net increment
  }
 
  return 
  // After one click: count is 1, not 3
}
 
// Proof that count is a snapshot — not a live reference:
function DelayedLog() {
  const [count, setCount] = useState(0)
 
  function handleClick() {
    setTimeout(() => {
      alert(count)   // alerts the value at click time, even if state changed since
    }, 5000)
  }
  // Click when count=0, increment to 5, wait 5s → alerts 0, not 5
  // The closure captured the snapshot value, not a live pointer
}

Functional updates — when you need the latest state

When the next state depends on the previous state, always use the functional form. It receives the most recent state value as its argument — bypassing the snapshot problem.

// Bug — snapshot problem with multiple rapid updates
function Counter() {
  const [count, setCount] = useState(0)
 
  function handleTripleClick() {
    setCount(count + 1)   // schedules 0+1 = 1
    setCount(count + 1)   // schedules 0+1 = 1 (count is still 0 in this snapshot)
    setCount(count + 1)   // schedules 0+1 = 1 — result: 1, not 3
  }
}
 
// Fix — functional update reads the pending state, not the snapshot
function Counter() {
  const [count, setCount] = useState(0)
 
  function handleTripleClick() {
    setCount(prev => prev + 1)   // pending: 0→1
    setCount(prev => prev + 1)   // pending: 1→2
    setCount(prev => prev + 1)   // pending: 2→3 — result: 3 ✓
  }
}
 
// Rule of thumb: if the new value depends on the old value, use (prev => newValue)
// If the new value is independent of the old value, direct assignment is fine
setCount(0)                   // reset — independent of old value, direct is fine
setCount(prev => prev + 1)   // increment — depends on old value, functional is required

Lazy initialisation — expensive initial state

useState accepts either a value or a function. When you pass a function, React calls it once on mount and uses the return value as the initial state. This is called lazy initialisation — it avoids re-running expensive computations on every render.

// Bad — parseData runs on EVERY render, even though the result is used only once
const [data, setData] = useState(parseData(rawInput))
 
// Good — parseData runs once on mount
const [data, setData] = useState(() => parseData(rawInput))
 
// More examples of when lazy init matters
const [items, setItems] = useState(() => JSON.parse(localStorage.getItem('cart') || '[]'))
const [config, setConfig] = useState(() => buildDefaultConfig(props.options))
const [matrix, setMatrix] = useState(() => createEmptyMatrix(100, 100))
 
// The function form: () => expensiveComputation()
// Not the result form: expensiveComputation()  ← runs every render

State updates are batched — React 17 vs React 18

Batching means React groups multiple setState calls into a single re-render. This avoids unnecessary intermediate renders and improves performance.

// React 17 — batching ONLY inside React event handlers
function handleClick() {
  setCount(c => c + 1)   // batched
  setName('Alice')        // batched — only ONE re-render
}
 
// React 17 — NOT batched inside setTimeout, Promise, or native events
setTimeout(() => {
  setCount(c => c + 1)   // render 1
  setName('Alice')        // render 2 — two separate re-renders in React 17
}, 0)
 
// React 18 — automatic batching EVERYWHERE
setTimeout(() => {
  setCount(c => c + 1)   // batched
  setName('Alice')        // batched — ONE re-render, even in setTimeout
}, 0)
 
// React 18 — opt out of batching when needed (rare)
import { flushSync } from 'react-dom'
 
flushSync(() => setCount(c => c + 1))   // renders immediately
flushSync(() => setName('Alice'))        // renders immediately — two renders
// Use flushSync when you need DOM measurements between state updates

Object and array state — immutability is mandatory

React uses Object.is comparison to decide whether to re-render. Mutating an object or array and passing the same reference back tells React nothing changed — it silently ignores the update.

// ❌ Mutation — React.memo and pure components bail out silently
const [user, setUser] = useState({ name: 'Alice', age: 30 })
 
function handleBirthday() {
  user.age += 1         // mutates in place — SAME reference
  setUser(user)         // Object.is(old, new) → true → React skips re-render
  // UI does not update — silent bug
}
 
// ✅ Spread — new reference, React detects change
function handleBirthday() {
  setUser(prev => ({ ...prev, age: prev.age + 1 }))   // new object, different reference
}
 
// ❌ Array mutation
const [items, setItems] = useState(['a', 'b', 'c'])
items.push('d')
setItems(items)   // same reference — no re-render
 
// ✅ Array immutable patterns
setItems(prev => [...prev, 'd'])                             // add
setItems(prev => prev.filter(item => item !== 'b'))          // remove
setItems(prev => prev.map(item => item === 'a' ? 'A' : item)) // update
setItems(prev => [...prev.slice(0, 1), 'X', ...prev.slice(2)]) // replace at index
 
// ✅ Nested object — spread each level
const [profile, setProfile] = useState({ user: { name: 'Alice', scores: [10, 20] } })
setProfile(prev => ({
  ...prev,
  user: {
    ...prev.user,
    name: 'Bob'   // only name changes, scores preserved
  }
}))
// Deep nesting → use Immer library to write mutable-looking code that produces immutable updates

useState vs useReducer — when to switch

// useState — good for: independent simple values, few state transitions
const [count, setCount]     = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError]     = useState(null)
 
// useReducer — good for: related state, complex transitions, many actions
const initialState = { count: 0, loading: false, error: null }
 
function reducer(state, action) {
  switch (action.type) {
    case 'INCREMENT': return { ...state, count: state.count + 1 }
    case 'FETCH_START': return { ...state, loading: true, error: null }
    case 'FETCH_SUCCESS': return { ...state, loading: false, data: action.payload }
    case 'FETCH_ERROR': return { ...state, loading: false, error: action.error }
    default: return state
  }
}
 
const [state, dispatch] = useReducer(reducer, initialState)
 
// Switch to useReducer when:
// - Multiple state variables always update together
// - Next state depends on the old state in complex ways
// - You want testable state transitions (pure function)
// - State logic is complex enough that you'd write helper functions for setState

Derived state — the most common useState anti-pattern

// ❌ Anti-pattern: using useState for values that derive from other state
function Cart({ items }) {
  const [total, setTotal] = useState(0)
 
  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price, 0))
  }, [items])   // extra render every time items changes
 
  // Problems:
  // 1. One render for items changing
  // 2. One render for setTotal updating total
  // 3. State is always one render behind
  // 4. total could be stale if you forget to update the effect
}
 
// ✅ Compute during render — always in sync, no extra renders
function Cart({ items }) {
  const total = items.reduce((sum, item) => sum + item.price, 0)
  // Derived synchronously — never stale, no extra render, no useEffect
}
 
// ✅ If computation is expensive, memoize it
function Cart({ items }) {
  const total = useMemo(
    () => items.reduce((sum, item) => sum + item.price, 0),
    [items]
  )
}

Initialising state from props — and when not to

// ✅ Acceptable: prop is the initial value, component owns state after that
function TextInput({ defaultValue }) {
  const [value, setValue] = useState(defaultValue)
  // defaultValue only used on mount — changes to defaultValue prop are ignored
  // Naming convention 'default' or 'initial' signals this intentional one-time use
}
 
// ❌ Bug: treating the prop as always-current state
function TextInput({ value }) {
  const [localValue, setLocalValue] = useState(value)
  // If parent changes 'value' prop, localValue stays stuck at the original mount value
  // React does not re-run useState when props change — only on mount
 
  // Fix 1: Fully controlled — remove useState, use value directly
  // Fix 2: key prop on the parent to force remount when value changes
  // Fix 3: useEffect to sync (usually the worst option — extra render, one frame late)
}
 
// ✅ The key prop reset pattern — elegant remount solution
// Parent:
<TextInput key={userId} defaultValue={userData.name} />
// Changing userId gives TextInput a new key → full remount → useState reinitialises
// No useEffect, no sync bugs, fresh component state

State colocation — where to put state

// Rule: put state as low as possible in the component tree
// Only lift state when two components need to share it
 
// ❌ Putting state too high — whole app re-renders on every keystroke
function App() {
  const [searchQuery, setSearchQuery] = useState('')
  return (
    <div>
      <HeavyDashboard />   {/* re-renders on every keystroke — unrelated! */}
      <SearchInput query={searchQuery} onChange={setSearchQuery} />
    </div>
  )
}
 
// ✅ Colocated — only SearchInput and SearchResults re-render
function SearchSection() {
  const [searchQuery, setSearchQuery] = useState('')
  return (
    <div>
      <SearchInput query={searchQuery} onChange={setSearchQuery} />
      <SearchResults query={searchQuery} />
    </div>
  )
}
 
function App() {
  return (
    <div>
      <HeavyDashboard />   {/* never re-renders due to search */}
      <SearchSection />
    </div>
  )
}

Controlled vs uncontrolled components

// Controlled — React state is the single source of truth
function ControlledInput() {
  const [value, setValue] = useState('')
  return (
    <input
      value={value}                   // React drives the input
      onChange={e => setValue(e.target.value)}
    />
  )
}
 
// Uncontrolled — DOM holds its own state, React reads it via ref
function UncontrolledInput() {
  const ref = useRef()
  function handleSubmit() {
    console.log(ref.current.value)   // read DOM value when needed
  }
  return <input ref={ref} defaultValue="" />
}
 
// When to use uncontrolled:
// - File inputs (can't control file inputs via React)
// - Integrating with non-React libraries
// - Large forms where per-keystroke re-renders are too expensive
// - When you only need the value on submit, not on every change
 
// When to use controlled (default choice):
// - Validation as user types
// - Conditional UI based on input value
// - Programmatic value changes
// - When multiple fields depend on each other

useState and closures — the event handler trap

// Classic closure trap in React — same as useEffect's stale closure
function MessageThread() {
  const [message, setMessage] = useState('')
 
  function handleSend() {
    setTimeout(() => {
      alert('Sending: ' + message)   // captures message at click time
    }, 3000)
  }
 
  // User types 'hello', clicks send, then immediately types 'world'
  // After 3 seconds: alert says 'hello', not 'world' — stale closure
 
  // Fix: use a ref to always read the latest value
  const messageRef = useRef(message)
  useEffect(() => { messageRef.current = message })  // keep ref in sync
 
  function handleSend() {
    setTimeout(() => {
      alert('Sending: ' + messageRef.current)  // always reads latest
    }, 3000)
  }
}

Resetting state — the key prop technique

// Problem: you want to reset a child component's state when a prop changes
// Option 1: useEffect to sync — causes extra render, one frame late, error-prone
// Option 2: lifting state up — sometimes overkill
// Option 3: key prop — forces complete remount, cleanest solution
 
function ProfilePage({ userId }) {
  return <Profile key={userId} userId={userId} />
  // When userId changes, React destroys the old Profile and creates a new one
  // useState inside Profile starts fresh — no stale state from previous user
}
 
// Useful for:
// - Resetting forms when switching between records
// - Reinitialising third-party widgets
// - Clearing error states when a route changes

Performance: when does useState cause unnecessary re-renders?

// Problem 1: State too high — causes siblings to re-render
// Solution: Colocate state (shown above)
 
// Problem 2: Setting state with the same value — React bails out
const [count, setCount] = useState(0)
setCount(0)   // Object.is(0, 0) → true → React skips re-render ✓
setCount([])  // Object.is([], []) → false → re-renders (new array reference) ✗
 
// Problem 3: Object state where only one field changes
const [state, setState] = useState({ a: 1, b: 2, c: 3 })
setState(prev => ({ ...prev, a: 99 }))
// Whole component re-renders even though b and c didn't change
// Fix: split into separate useState calls if they update independently
const [a, setA] = useState(1)   // updating a doesn't re-render b or c consumers
const [b, setB] = useState(2)   // (if they are in separate components)
 
// Problem 4: State updates during render — causes double render
function Component() {
  const [count, setCount] = useState(0)
  if (someCondition) {
    setCount(1)   // setState during render — forces immediate re-render
  }
  // React handles this case: it re-renders immediately without committing
  // But it's a performance hit and usually signals derived state that should be computed
}

Common Misconceptions

⚠️

Many developers think setState immediately updates the state variable — but setState schedules a re-render. The current render's state variable does not change. If you call setCount(5) and immediately read count, it still holds the old value. The updated value is only available in the next render. This is why three setCount(count + 1) calls in one handler produce an increment of 1, not 3.

⚠️

Many developers think they can mutate state objects directly and call setState to trigger a re-render — but React uses Object.is comparison. If you pass the same object reference back (even with mutated properties), React sees no change and skips the re-render entirely. You must always produce a new object or array reference. This is a silent bug — no error, no warning, just a UI that doesn't update.

⚠️

Many developers think the initial value of useState runs once — but if you pass a value directly (not a function), the expression is evaluated on every render. It's just that React ignores the result after the first render. For expensive computations, pass a function: useState(() => expensiveSetup()). React calls it once. Passing the result directly means the expensive function runs on every render needlessly.

⚠️

Many developers think calling multiple setStates causes multiple re-renders — but React batches them. In React 18, all setState calls — including those inside setTimeout, fetch callbacks, and native event handlers — are batched into one re-render. In React 17, batching only applied inside React event handlers. Understanding which version you're targeting matters for predicting re-render counts.

⚠️

Many developers think useState for form fields always requires a controlled component — but uncontrolled inputs (with useRef) are valid and often better when you only need the value on submit. Controlled components re-render on every keystroke. For large forms with many fields, this matters. React Hook Form uses uncontrolled inputs internally for this exact reason.

⚠️

Many developers think derived state should be stored in useState and synced with useEffect — but any value computable from props or existing state should be computed during render. Storing derived state in useState introduces a guaranteed extra render, a period of stale state, and a useEffect that can be forgotten or incorrectly updated. Compute during render, memoize with useMemo if expensive.

⚠️

Many developers think useState setter functions change identity between renders — but React guarantees setState functions are stable references. They never change between renders. This means you do not need to include them in useEffect dependency arrays or wrap them in useCallback. Adding them to deps is harmless but unnecessary.

⚠️

Many developers think they need to call useState with a complete initial state containing all fields — but you can call useState multiple times for independent pieces of state. Splitting state into multiple useState calls means each one updates independently — changes to one don't cause consumers of another to re-render (when they're in separate components). Group state together only when the values always change together.

⚠️

Many developers think changing a key prop just reinitialises state — but changing a key prop tells React to destroy the existing component instance and create a completely new one. Effects are cleaned up, the DOM element is replaced, and all state is reset to initial values. It is a full remount, not just a state reset. This is intentional and is the cleanest way to reset a component's state from outside.

Where You'll See This in Real Code

GitHub's pull request review interface uses useState for the inline comment draft. The draft text is controlled state synced to a textarea. When the user submits, the comment is optimistically added to the list (another useState) before the API call confirms. If the API fails, the optimistic update is rolled back. This is the optimistic UI pattern built on two coordinated useState calls.

Swiggy and Zomato's cart implementation uses a single useState holding an array of cart items. Every add, remove, and quantity change produces a new array via immutable update patterns. The quantity badge in the navbar is derived state computed from the same array during render — not stored in a separate useState. One source of truth, derived UI everywhere.

Notion's block editor tracks the currently selected block ID in useState at the editor root level. When selection changes, only components that consume the selection context re-render. The block content itself is separate state colocated to each block component — typing in one block does not re-render other blocks. This is the colocation principle applied at scale.

Figma's property panel uses controlled inputs for every design property (x, y, width, height, color). Each input is backed by useState that mirrors the selected element's property. On blur or Enter, the value is committed to the design document. On Escape, the input resets to the last committed value via a reset pattern. Controlled inputs make this two-phase commit pattern trivial to implement.

Linear's issue modal uses the key prop reset pattern extensively. When a user navigates between issues, the modal's key changes to the issue ID. This unmounts the old modal and mounts a fresh one — all form state (unsaved edits, expanded sections, active tabs) resets cleanly without any cleanup code in the component itself. One line on the parent eliminates an entire category of stale-state bugs.

Razorpay's checkout form stores each form field in separate useState calls rather than one object, because each field validates independently and updates at different times. Card number, expiry, CVV, and name each have their own state and their own error state. Keeping them separate means typing in the card number field does not trigger re-renders of the CVV field's subtree — a meaningful performance difference for a payment form that must be fast and reliable.

Interview Cheat Sheet

  • useState returns [currentValue, setter] — current value is a snapshot of this render
  • Calling setter does NOT change the variable now — it schedules a re-render
  • Next render: React hands back the updated value from its internal hook storage
  • Three setCount(count+1) in one handler = net increment of 1, not 3 (snapshot problem)
  • Functional update setCount(prev => prev+1) reads pending state — always correct for increments
  • Use functional update whenever new state depends on old state
  • Lazy init: useState(() => expensiveSetup()) — function called once on mount, not every render
  • Object.is comparison — same primitive value or same reference = no re-render
  • Mutating objects/arrays and passing same reference = silent no-render bug
  • Always produce new reference for objects: setUser(prev => ({ ...prev, name: 'Bob' }))
  • React 18: all setState calls batched everywhere — setTimeout, fetch, native events
  • React 17: batching only inside React synthetic event handlers
  • flushSync() forces synchronous DOM update — opt out of batching (rare)
  • useState setters are stable references — never change — safe to omit from dependency arrays
  • Derived state belongs in render, not useState + useEffect — avoids extra renders and stale values
  • Split unrelated state into separate useState calls — each updates independently
  • Group related state in one object only when values always change together
  • Initialising from prop: only the initial render uses it — prop changes after mount are ignored
  • Key prop on parent: cleanest way to reset all child state — causes full remount
  • Controlled input: useState drives value — validates on change, programmatic updates easy
  • Uncontrolled input: useRef reads DOM value on demand — better for large forms, submit-only reads
  • Colocate state as low as possible — lift only when siblings must share it
  • useReducer > multiple useState when: transitions are complex, state fields update together, logic needs testing
💡

How to Answer in an Interview

  • 1.Open with the snapshot model — say "state in React is a snapshot: within one render, the state variable never changes, even if you call setState. The new value appears in the next render." Then demonstrate with the three-setCount-calls example. This immediately separates you from candidates who think setState is synchronous.
  • 2.The functional update question is guaranteed in senior interviews. When asked "how do you correctly increment state multiple times in one handler", walk through: why count+1 three times gives 1 (snapshot), why prev => prev+1 three times gives 3 (processes pending queue). Then give the rule: "if new state depends on old state, always use the functional form."
  • 3.When asked about useState vs useReducer, give a sharp heuristic: "I reach for useReducer when three or more state transitions share the same state shape, when the next state depends on the action type rather than just one value, or when I want to extract and test the state logic in isolation." Never say "they're basically the same" — they're not.
  • 4.The lazy initialisation question trips up most candidates. Demonstrate: useState(expensiveCalc()) vs useState(() => expensiveCalc()). Explain that without the function wrapper, the expression runs every render and React silently discards the result after the first. With the function wrapper, React calls it once. The fix is always "wrap in a function."
  • 5.When discussing object state, always mention Object.is comparison and the immutability requirement. Then demonstrate the silent mutation bug: object mutated in place, setState called with same reference, no re-render. This shows you understand why the immutability rule exists, not just that it exists. Mentioning Immer as a solution for deep nested updates signals practical experience.
  • 6.The key prop reset pattern is a signal of genuine React experience. When interviewers ask "how do you reset a child component's state from the parent?", most candidates reach for useEffect. The correct senior answer is: "Change the key prop — React treats it as a different component and remounts fresh. No useEffect, no sync logic, no extra renders."
  • 7.Connect useState to rendering performance. Mention state colocation: "the single most impactful state-related performance optimisation is putting state as low in the tree as possible. Every setState call re-renders the component and its entire subtree. State that lives too high re-renders unrelated components on every update."
  • 8.Controlled vs uncontrolled is a question that reveals practical experience. Say: "I default to controlled inputs because they make validation, programmatic updates, and conditional logic straightforward. I use uncontrolled inputs — typically with React Hook Form — for performance-sensitive large forms where per-keystroke re-renders are measurable." Mentioning React Hook Form's uncontrolled internals shows you've read beyond the docs.

Practice Questions

No questions tagged to this topic yet.

Related Topics

useEffect Hook — Complete React Interview Guide
Beginner·4–8 Qs
useRef Hook — Complete React Interview Guide
Beginner·4–8 Qs
🎯

Can you answer these under pressure?

Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.

Practice Free →Try Output Quiz