Explain what useState() is for (local component state), what it returns, how updates trigger re-renders, and the key mental models interviewers expect: initial state (including lazy init), functional updates, batching, Object.is bailout, and why you must treat state as immutable.
Why useState() exists in React (and what it actually guarantees)
Use guided tracks for structured prep, then practice company-specific question sets when you want targeted interview coverage.
Core ideauseState() gives a function component persistent local state across renders. When you call its setter, React schedules a re-render of that component so the UI can be recalculated from the new state. The key model is: UI = f(props, state) — you don’t manually update the DOM.
What you get | What it means | Why it matters |
|---|---|---|
A state value | The current snapshot for this render | Rendering reads state; it does not mutate it |
A setter function | Schedules an update (it does not instantly change the variable) | React decides when to render/commit; avoids inconsistent UI |
State is tied to position in the tree | State is preserved across renders while the component stays mounted | Unmount/remount (or key changes) resets state |
import React from 'react';
export default function Counter() {
const [count, setCount] = React.useState(0);
return (
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}
Why the functional update form matters
If the next state depends on the previous state, use setX(prev => next). This avoids stale reads when multiple updates happen close together or are batched.
function Bad() {
const [n, setN] = React.useState(0);
// ❌ can be stale if called multiple times in one tick
const incTwice = () => {
setN(n + 1);
setN(n + 1);
};
return <button onClick={incTwice}>{n}</button>;
}
function Good() {
const [n, setN] = React.useState(0);
// ✅ each update reads the latest queued value
const incTwice = () => {
setN((x) => x + 1);
setN((x) => x + 1);
};
return <button onClick={incTwice}>{n}</button>;
}
Interview-level detail | Correct statement | Practical takeaway |
|---|---|---|
Initial state |
| If you need expensive computation, use lazy init: |
Bailout | If the new state is | Don’t create new objects/arrays unless something actually changed |
Batching | React may batch multiple state updates into one render | Don’t rely on “immediate” state after calling the setter |
Immutability | Mutating state in place breaks React’s change detection assumptions | Always create new references when updating objects/arrays |
function TodoApp() {
// ✅ Lazy init (runs only once on mount)
const [todos, setTodos] = React.useState(() => {
const raw = localStorage.getItem('todos');
return raw ? JSON.parse(raw) : [];
});
// ✅ Immutable update (new array)
function addTodo(text) {
setTodos((prev) => [...prev, { id: crypto.randomUUID(), text }]);
}
// ❌ Bad: mutating state
function badAdd(text) {
todos.push({ id: 'x', text });
setTodos(todos); // same reference -> may not re-render
}
return (
<div>
<button onClick={() => addTodo('Learn useState')}>Add</button>
<ul>{todos.map((t) => <li key={t.id}>{t.text}</li>)}</ul>
</div>
);
}
When useState is the wrong tooluseState is for local UI state (toggles, inputs, selected tabs). If updates become complex (many transitions, derived actions), useReducer often reads better. If the state is global/shared across many branches, you typically lift it up, use Context carefully, or use a store.
useState() adds persistent local state to function components. It returns [state, setState]; calling the setter schedules a re-render so React can recompute the UI. Use functional updates when the next state depends on the previous state, update objects/arrays immutably, and remember that React may batch updates and can bail out when the new state is Object.is-equal to the old state.