Master useState: snapshot model, functional updates, immutability, batching, lazy init, controlled inputs, and every edge case asked in React interviews.
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.
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.
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
}
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
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
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
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 — 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
// ❌ 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]
)
}
// ✅ 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
// 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 — 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
// 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)
}
}
// 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
// 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
}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.
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.
No questions tagged to this topic yet.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.