How useState works in React?
useState is React's hook that lets a functional component own local state, returning a current value and a setter function that schedules a re-render.
Theory
TL;DR
- Think of a vending machine display: shows the current count, you press a button (the setter) to queue a new value for the next cycle (re-render).
- State survives re-renders. A plain variable declared inside a component resets every time the function runs.
- The setter does not update state instantly. It queues an update, and React batches multiple calls into one re-render.
- Use
useStatefor local UI data. For shared state across components, useuseContext. For complex update logic, useuseReducer. - When the new value depends on the current one, always use the functional form:
setState(prev => next).
Quick example
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0); // [current value, setter]
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>
+ {/* functional update reads latest queued value */}
</button>
</div>
);
}useState(0) sets the initial value to 0. Clicking the button calls the setter with a function that receives the latest state and returns the next value. React schedules a re-render and the component shows 1.
Key difference
Unlike a plain variable, state persists between renders. But the setter does not change the value mid-render. It enqueues an update, and React applies all queued updates together before the next paint. This is why reading count right after setCount(count + 1) still gives you the old value. The new value only exists in the next render's scope.
When to use
- Local UI toggle (modal open/close, dropdown) - one
useStateper boolean. - Controlled form inputs -
useStateper field, or a singleuseState({})for the whole form. - Values that change independently - separate
useStatecalls, not one large object. - Update logic that involves multiple sub-values or conditions -
useReduceris a cleaner fit. - State shared by several components - lift it up or use
useContextwith a top-leveluseState. - New value depends on current state - always use the functional form to avoid stale reads.
How useState stores state
React keeps each component's hook data in a fiber node, an internal object in the component tree. Each useState call gets one slot in a linked list attached to that fiber. The first useState maps to slot 0, the second to slot 1, and so on.
This is exactly why calling hooks conditionally breaks React. If a useState sits inside an if block, the slot assignment shifts between renders and React reads the wrong state from the wrong slot. The "only call hooks at the top level" rule exists for this reason.
When you call the setter (internally called dispatchAction), React adds an update object to that slot's queue. On the next render, React replays all hooks in order, processes each update queue, and computes new state. In React 18, updates inside events, setTimeout, and Promise.then are all batched automatically into one re-render. In React 17, only event handler updates were batched.
Lazy initialization
Pass a function to useState when computing the initial value is expensive:
// Runs compute() on every render, result ignored after mount
const [data, setData] = useState(compute());
// Runs compute() once on mount only
const [data, setData] = useState(() => compute());This pattern is called lazy initialization. React calls the function exactly once and uses the return value as the starting state. Good candidates: reading from localStorage, parsing a URL, or processing a large initial dataset.
Common mistakes
Mutating state directly:
const [arr, setArr] = useState([1, 2]);
arr.push(3); // React sees the same reference, no re-render
setArr(arr); // Same reference again, React bails out
// Fix:
setArr([...arr, 3]);
// or functional form:
setArr(a => [...a, 3]);Reading state right after setting it:
const handleClick = () => {
setCount(count + 1);
console.log(count); // Logs old value. Update is queued, not applied yet.
};Stale closure when calling the setter multiple times:
// Both calls read the same render-time count
const handleClick = () => {
setCount(count + 1); // count=0, queues: set to 1
setCount(count + 1); // count=0, queues: set to 1 again
// Result: 1, not 2
};
// Fix: functional updates chain correctly
const handleClick = () => {
setCount(c => c + 1); // c=0, result: 1
setCount(c => c + 1); // c=1, result: 2
};Shallow copying nested objects:
const [user, setUser] = useState({ name: 'Alex', settings: { theme: 'light' } });
// Wrong: settings still points to the original object
user.settings.theme = 'dark';
setUser(user); // same reference, React skips re-render
// Right: copy every level you change
setUser(u => ({
...u,
settings: { ...u.settings, theme: 'dark' }
}));
// Or split into separate states when values are unrelated:
const [theme, setTheme] = useState('light');Calling the setter during render:
function Bad() {
const [x, setX] = useState(0);
setX(1); // Infinite loop: setter triggers render, render calls setter again
// Fix: move to useEffect or an event handler
}One pattern I've seen trip up even experienced developers: calling setState inside a forEach that loops over the current state. Each iteration captures the same stale array, so only the last update survives. The fix is always the functional form, or building the full next state before calling the setter once.
Real-world usage
- React TodoMVC pattern -
useState([])for the task list, a separateuseStatefor the active filter value. - Next.js forms -
useState({})for controlled inputs, validated on submit. - Theme switcher -
useState('light')synced tolocalStorageon every change. - React DevTools - tracks component state internally using multiple
useStatehooks per component. - Remix client-side validation -
useStateholds error messages before the form posts to the server.
Follow-up questions
Q: What happens if you call the setter during render?
A: React throws "Too many re-renders." The setter triggers a re-render, which calls the setter again in an infinite loop. Move state updates to event handlers or useEffect.
Q: What is lazy initialization and when should you use it?
A: Passing a function to useState instead of a value. React calls it once on mount and ignores it on subsequent renders. Use it when computing the initial value is expensive, like reading localStorage or parsing a large dataset.
Q: What is the difference between setState(value) and setState(fn)?
A: setState(value) captures the value from the current render closure. If multiple setters batch together, they all read the same stale value. setState(fn) receives the latest value from the update queue as an argument, so chained updates work correctly.
Q: How does React 18 auto-batching change useState behavior?
A: Before React 18, only updates inside synthetic event handlers were batched. React 18 batches updates in setTimeout, Promise.then, and native event listeners too, reducing unnecessary re-renders. Code that relied on getting a separate re-render per async setter call may need adjustment.
Q: (Senior) Why must hooks always be called in the same order?
A: React tracks each useState by its position in a per-fiber linked list. If the order shifts between renders (a hook inside an if), the index mismatches and React reads the wrong state from the wrong slot. The hooks linter catches this at the tooling level.
Q: (Senior) If you call setCount with the same value as current state, does React re-render?
A: No. React uses Object.is to compare the new value to the current one. If they match, React bails out and skips the re-render entirely. This is a built-in optimization, not something you need to add manually.
Examples
Basic: show and hide a modal
import { useState } from 'react';
function Modal() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(o => !o)}>
{isOpen ? 'Close' : 'Open'} modal
</button>
{isOpen && <div className="modal">Modal content here</div>}
</div>
);
}isOpen starts as false. The button flips it with a functional update. React re-renders and conditionally renders the modal div. No external state management needed for something this local.
Intermediate: controlled login form
import { useState } from 'react';
function LoginForm() {
const [form, setForm] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const handleChange = (e) => {
const { name, value } = e.target;
setForm(f => ({ ...f, [name]: value })); // merge one field, keep the rest
};
const handleSubmit = (e) => {
e.preventDefault();
if (!form.email.includes('@')) {
setError('Enter a valid email');
return;
}
setError('');
// send form data to API
};
return (
<form onSubmit={handleSubmit}>
<input name="email" value={form.email} onChange={handleChange} />
<input name="password" type="password" value={form.password} onChange={handleChange} />
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit">Login</button>
</form>
);
}Two separate useState calls handle different concerns: one for field values, one for error messages. The functional update in handleChange spreads the previous form and overrides only the changed field, which avoids the shallow copy trap on nested state.
Advanced: stale closure with React 18 batching
import { useState } from 'react';
function BadCounter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // count=0, queues: set to 1
setCount(count + 1); // count=0, same closure, queues: set to 1 again
// After click: count = 1, not 2
};
return <button onClick={handleClick}>Bad: {count}</button>;
}
function GoodCounter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(c => c + 1); // c=0, queues: 1
setCount(c => c + 1); // c=1, queues: 2
// After click: count = 2
};
return <button onClick={handleClick}>Good: {count}</button>;
}BadCounter uses count directly in both calls. React 18 batches them into one re-render, but both closures still capture the same render-time count (which is 0). The update queue ends up with two identical instructions: "set to 1." Result: 1.
GoodCounter uses functional updates. React passes the latest queued value into each callback, so they chain: 0 -> 1 -> 2. This is the safe pattern for any handler that calls the same setter more than once.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.