What Is useState?
useState is a React hook that lets you add state to function components. Before hooks (React 16.8), only class components could hold state. Now, useState is the primary way to manage local component state.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
When you call setCount, React schedules a re-render. The component function runs again, and useState returns the new value.
Syntax and Initialization
Basic Syntax
const [stateValue, setterFunction] = useState(initialValue);
- stateValue โ the current value of this state variable
- setterFunction โ a function to update the state and trigger a re-render
- initialValue โ the value used on the first render only
The names are arbitrary (array destructuring), but the convention is [thing, setThing].
Types of Initial Values
useState accepts any JavaScript value as the initial state.
// Primitive values
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// Objects
const [user, setUser] = useState({ name: 'Alice', age: 30 });
// Arrays
const [items, setItems] = useState([]);
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', done: false },
{ id: 2, text: 'Build project', done: false },
]);
Lazy Initialization
If computing the initial value is expensive, pass a function instead of a value. React will only call this function on the first render.
// BAD โ createInitialState() runs on EVERY render (but result is ignored after first)
const [state, setState] = useState(createExpensiveInitialState());
// GOOD โ function only called on first render
const [state, setState] = useState(() => createExpensiveInitialState());
Real-world example: reading from localStorage.
function usePersistentCounter() {
const [count, setCount] = useState(() => {
// Only runs once, on mount
const saved = localStorage.getItem('counter');
return saved !== null ? JSON.parse(saved) : 0;
});
useEffect(() => {
localStorage.setItem('counter', JSON.stringify(count));
}, [count]);
return [count, setCount];
}
Without lazy init:
Render 1: JSON.parse(localStorage.getItem('counter')) <-- used
Render 2: JSON.parse(localStorage.getItem('counter')) <-- wasted
Render 3: JSON.parse(localStorage.getItem('counter')) <-- wasted
With lazy init (pass function):
Render 1: () => JSON.parse(...) <-- called, result used
Render 2: (function ignored) <-- no work done
Render 3: (function ignored) <-- no work done
Multiple State Variables vs Single Object
Multiple Variables (Recommended for Independent Values)
function SignupForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [agreeToTerms, setAgreeToTerms] = useState(false);
return (
<form>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<label>
<input
type="checkbox"
checked={agreeToTerms}
onChange={e => setAgreeToTerms(e.target.checked)}
/>
I agree
</label>
</form>
);
}
Single Object (Better for Related Values)
function SignupForm() {
const [form, setForm] = useState({
name: '',
email: '',
password: '',
agreeToTerms: false,
});
const updateField = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }));
};
return (
<form>
<input
value={form.name}
onChange={e => updateField('name', e.target.value)}
/>
<input
value={form.email}
onChange={e => updateField('email', e.target.value)}
/>
<input
type="password"
value={form.password}
onChange={e => updateField('password', e.target.value)}
/>
<label>
<input
type="checkbox"
checked={form.agreeToTerms}
onChange={e => updateField('agreeToTerms', e.target.checked)}
/>
I agree
</label>
</form>
);
}
When to Use Which
| Approach | Use When |
|---|---|
Multiple useState | Values change independently, simple types |
Single object useState | Values are related, change together, or there are many fields |
useReducer | Complex state logic, multiple actions, state transitions |
Updating State
Direct Updates
For simple cases where the new value doesn't depend on the old value:
setCount(5);
setName('Bob');
setIsOpen(true);
setItems([1, 2, 3]);
Functional Updates
When the new state depends on the previous state, pass a function. This avoids stale closure issues.
// Direct update โ uses the value of count from this render's closure
setCount(count + 1);
// Functional update โ receives the latest state value
setCount(prevCount => prevCount + 1);
Why functional updates matter:
function Counter() {
const [count, setCount] = useState(0);
const incrementThrice = () => {
// BUG: All three read the same `count` from closure (e.g., 0)
setCount(count + 1); // 0 + 1 = 1
setCount(count + 1); // 0 + 1 = 1 (count is still 0 in this closure)
setCount(count + 1); // 0 + 1 = 1
// Result: count = 1, NOT 3
};
const incrementThriceCorrect = () => {
// CORRECT: Each receives the latest pending state
setCount(c => c + 1); // 0 -> 1
setCount(c => c + 1); // 1 -> 2
setCount(c => c + 1); // 2 -> 3
// Result: count = 3
};
return (
<div>
<p>{count}</p>
<button onClick={incrementThrice}>+3 (buggy)</button>
<button onClick={incrementThriceCorrect}>+3 (correct)</button>
</div>
);
}
Rule of thumb: Use functional updates whenever the new state is derived from the old state.
State Batching
React batches multiple state updates within the same synchronous event handler into a single re-render for performance.
Batching in Event Handlers
function BatchDemo() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const [text, setText] = useState('hello');
const handleClick = () => {
// All three updates are batched โ ONE re-render
setCount(c => c + 1);
setFlag(f => !f);
setText('world');
// Component re-renders once here, with all three updates applied
};
console.log('Rendered'); // Logs once per click
return (
<div>
<p>{count} | {flag.toString()} | {text}</p>
<button onClick={handleClick}>Update all</button>
</div>
);
}
Automatic Batching (React 18+)
Before React 18, batching only worked inside React event handlers. Since React 18, batching works everywhere:
function AutoBatchDemo() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const handleClick = () => {
// React 18: ALL of these are batched
// Inside setTimeout โ batched since React 18
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// ONE re-render
}, 1000);
// Inside Promise โ batched since React 18
fetch('/api/data').then(() => {
setCount(c => c + 1);
setFlag(f => !f);
// ONE re-render
});
};
return <button onClick={handleClick}>Async update</button>;
}
Opting Out of Batching (Rare)
If you need an update to happen immediately (before the next one), use flushSync:
import { flushSync } from 'react-dom';
function FlushExample() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const handleClick = () => {
flushSync(() => {
setCount(c => c + 1);
});
// DOM is updated here, component has re-rendered
flushSync(() => {
setFlag(f => !f);
});
// DOM is updated again
};
}
You almost never need flushSync. It's an escape hatch for rare cases like measuring DOM after an update.
Working with Objects and Arrays
Updating Objects (Never Mutate)
function UserProfile() {
const [user, setUser] = useState({
name: 'Alice',
email: '[email protected]',
preferences: {
theme: 'dark',
language: 'en',
},
});
// Update a top-level field
const updateName = (name) => {
setUser(prev => ({ ...prev, name }));
};
// Update a nested field
const updateTheme = (theme) => {
setUser(prev => ({
...prev,
preferences: {
...prev.preferences,
theme,
},
}));
};
return (
<div>
<p>{user.name} โ {user.preferences.theme}</p>
<button onClick={() => updateName('Bob')}>Change name</button>
<button onClick={() => updateTheme('light')}>Toggle theme</button>
</div>
);
}
Updating Arrays
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', done: false },
{ id: 2, text: 'Build app', done: false },
]);
// Add item
const addTodo = (text) => {
setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);
};
// Remove item
const removeTodo = (id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
// Update item
const toggleTodo = (id) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
};
// Replace item
const updateText = (id, newText) => {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
)
);
};
// Insert at position
const insertAt = (index, todo) => {
setTodos(prev => [
...prev.slice(0, index),
todo,
...prev.slice(index),
]);
};
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => toggleTodo(todo.id)}>Toggle</button>
<button onClick={() => removeTodo(todo.id)}>Delete</button>
</div>
))}
<button onClick={() => addTodo('New todo')}>Add</button>
</div>
);
}
Array Operations Cheat Sheet
| Operation | Mutating (WRONG) | Immutable (CORRECT) |
|---|---|---|
| Add | arr.push(item) | [...arr, item] |
| Remove | arr.splice(i, 1) | arr.filter(x => x.id !== id) |
| Replace | arr[i] = newItem | arr.map(x => x.id === id ? newItem : x) |
| Sort | arr.sort() | [...arr].sort() |
| Reverse | arr.reverse() | [...arr].reverse() |
| Insert | arr.splice(i, 0, item) | [...arr.slice(0,i), item, ...arr.slice(i)] |
Don't Mutate State Directly
This is the most common useState mistake. Let's understand why mutation breaks React.
Why Mutation Fails
function BrokenCounter() {
const [user, setUser] = useState({ name: 'Alice', clicks: 0 });
const handleClick = () => {
user.clicks += 1; // Mutates the existing object
setUser(user); // Same reference! React sees no change
// React skips re-render because Object.is(oldState, newState) === true
};
return (
<div>
<p>{user.name}: {user.clicks} clicks</p>
<button onClick={handleClick}>Click</button>
</div>
);
}
Why mutation fails:
const obj = { clicks: 0 }
useState(obj) // React stores reference to obj
obj.clicks = 1 // Mutate the same object
setUser(obj) // Pass same reference
Object.is(obj, obj) // true โ React thinks nothing changed
// No re-render!
Why immutable update works:
const obj = { clicks: 0 }
useState(obj) // React stores reference to obj
const newObj = { ...obj, clicks: 1 } // New object
setUser(newObj) // Pass new reference
Object.is(obj, newObj) // false โ React sees a change
// Re-render!
Fixed Version
function WorkingCounter() {
const [user, setUser] = useState({ name: 'Alice', clicks: 0 });
const handleClick = () => {
setUser(prev => ({
...prev,
clicks: prev.clicks + 1, // New object with updated field
}));
};
return (
<div>
<p>{user.name}: {user.clicks} clicks</p>
<button onClick={handleClick}>Click</button>
</div>
);
}
Closure Over Stale State
This is the second most common mistake. It happens when a callback captures the state value from an old render.
The Problem
function Timer() {
const [count, setCount] = useState(0);
const startTimer = () => {
setInterval(() => {
// BUG: count is always 0 (captured from the render where startTimer was called)
setCount(count + 1);
}, 1000);
};
return (
<div>
<p>{count}</p>
<button onClick={startTimer}>Start</button>
</div>
);
}
Render 1: count = 0
startTimer captures count = 0
setInterval callback: setCount(0 + 1) = 1 (always)
Render 2: count = 1
But the old setInterval still has count = 0 in its closure
setCount(0 + 1) = 1 <-- stuck at 1!
The Fix: Functional Updates
function Timer() {
const [count, setCount] = useState(0);
const startTimer = () => {
setInterval(() => {
// CORRECT: functional update always receives latest state
setCount(prev => prev + 1);
}, 1000);
};
return (
<div>
<p>{count}</p>
<button onClick={startTimer}>Start</button>
</div>
);
}
Another Common Stale Closure
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleSearch = () => {
// BUG if called from setTimeout or event listener:
// query may be stale
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
};
// This is fine in an event handler (query is current at click time)
// But would be stale if passed to setInterval or addEventListener
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<button onClick={handleSearch}>Search</button>
</div>
);
}
Common Patterns and Recipes
Toggle Pattern
function Toggle() {
const [isOn, setIsOn] = useState(false);
return (
<button onClick={() => setIsOn(prev => !prev)}>
{isOn ? 'ON' : 'OFF'}
</button>
);
}
Previous Value Pattern
function CounterWithPrevious() {
const [count, setCount] = useState(0);
const [prevCount, setPrevCount] = useState(null);
const increment = () => {
setPrevCount(count);
setCount(c => c + 1);
};
return (
<div>
<p>Current: {count}</p>
<p>Previous: {prevCount ?? 'none'}</p>
<button onClick={increment}>+1</button>
</div>
);
}
Reset Pattern
function ResettableForm() {
const initialState = { name: '', email: '', message: '' };
const [form, setForm] = useState(initialState);
const updateField = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }));
};
const reset = () => setForm(initialState);
const handleSubmit = (e) => {
e.preventDefault();
console.log(form);
reset();
};
return (
<form onSubmit={handleSubmit}>
<input
value={form.name}
onChange={e => updateField('name', e.target.value)}
placeholder="Name"
/>
<input
value={form.email}
onChange={e => updateField('email', e.target.value)}
placeholder="Email"
/>
<textarea
value={form.message}
onChange={e => updateField('message', e.target.value)}
placeholder="Message"
/>
<button type="submit">Send</button>
<button type="button" onClick={reset}>Reset</button>
</form>
);
}
Undo Pattern
function UndoableCounter() {
const [history, setHistory] = useState([0]);
const [index, setIndex] = useState(0);
const current = history[index];
const set = (value) => {
const newHistory = history.slice(0, index + 1);
newHistory.push(value);
setHistory(newHistory);
setIndex(newHistory.length - 1);
};
const undo = () => {
if (index > 0) setIndex(i => i - 1);
};
const redo = () => {
if (index < history.length - 1) setIndex(i => i + 1);
};
return (
<div>
<p>Value: {current}</p>
<button onClick={() => set(current + 1)}>+1</button>
<button onClick={() => set(current - 1)}>-1</button>
<button onClick={undo} disabled={index === 0}>Undo</button>
<button onClick={redo} disabled={index === history.length - 1}>Redo</button>
<p>History: [{history.join(', ')}] (position {index})</p>
</div>
);
}
Custom Hooks with useState
Custom hooks let you extract reusable stateful logic.
useToggle
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = () => setValue(prev => !prev);
const setTrue = () => setValue(true);
const setFalse = () => setValue(false);
return { value, toggle, setTrue, setFalse };
}
// Usage
function Modal() {
const { value: isOpen, toggle, setFalse: close } = useToggle(false);
return (
<div>
<button onClick={toggle}>Toggle Modal</button>
{isOpen && (
<div className="modal">
<p>Modal content</p>
<button onClick={close}>Close</button>
</div>
)}
</div>
);
}
useCounter
function useCounter(initialValue = 0, { min = -Infinity, max = Infinity } = {}) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => Math.min(c + 1, max));
const decrement = () => setCount(c => Math.max(c - 1, min));
const reset = () => setCount(initialValue);
const set = (value) => setCount(Math.min(Math.max(value, min), max));
return { count, increment, decrement, reset, set };
}
// Usage
function QuantitySelector() {
const { count, increment, decrement, reset } = useCounter(1, { min: 1, max: 99 });
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
<button onClick={reset}>Reset</button>
</div>
);
}
useLocalStorage
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item !== null ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = (value) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
try {
localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
};
return [storedValue, setValue];
}
// Usage
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
return (
<div>
<select value={theme} onChange={e => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<input
type="range"
min={12}
max={24}
value={fontSize}
onChange={e => setFontSize(Number(e.target.value))}
/>
</div>
);
}
useState Under the Hood
Understanding how React stores state helps you avoid bugs.
The Rules of Hooks
- Only call hooks at the top level โ never inside loops, conditions, or nested functions
- Only call hooks from React functions โ components or custom hooks
// WRONG โ conditional hook call
function Bad({ showName }) {
if (showName) {
const [name, setName] = useState(''); // React can't track this reliably
}
const [age, setAge] = useState(0);
}
// RIGHT โ always call, conditionally use
function Good({ showName }) {
const [name, setName] = useState('');
const [age, setAge] = useState(0);
return (
<div>
{showName && <input value={name} onChange={e => setName(e.target.value)} />}
<input value={age} onChange={e => setAge(Number(e.target.value))} />
</div>
);
}
How React Tracks State
React uses the call order of hooks to associate each useState with its stored value. This is why hooks must be called in the same order on every render.
Render 1:
useState('') -> hook[0] = '' (name)
useState(0) -> hook[1] = 0 (age)
Render 2:
useState('') -> hook[0] = 'Alice' (name โ was updated)
useState(0) -> hook[1] = 25 (age โ was updated)
If a conditional skipped the first hook:
useState(0) -> hook[0] = 'Alice' // BUG! age gets name's value
Comparing State Management Approaches
| Approach | Complexity | Best For |
|---|---|---|
useState (primitive) | Low | Single values: toggles, counts, strings |
useState (object) | Medium | Related fields: form data, coordinates |
useReducer | Medium-High | Complex transitions: multi-step forms, state machines |
Context + useState | Medium | Shared state across distant components |
Context + useReducer | High | App-wide state with complex logic |
| External library (Zustand, Redux) | High | Large apps, dev tools, middleware |
Debugging useState
React DevTools
Open React DevTools in your browser, select a component, and you'll see its state listed as State with each useState value numbered.
Logging State Changes
function DebuggableComponent() {
const [count, setCount] = useState(0);
const setCountWithLog = (valueOrFn) => {
setCount(prev => {
const next = typeof valueOrFn === 'function' ? valueOrFn(prev) : valueOrFn;
console.log('count:', prev, '->', next);
return next;
});
};
return <button onClick={() => setCountWithLog(c => c + 1)}>{count}</button>;
}
Common Debugging Checklist
Symptom: UI not updating after setState
[ ] Are you mutating state instead of creating new object/array?
[ ] Are you passing the same reference? (Object.is check)
[ ] Is the state in the wrong component?
Symptom: State stuck on old value
[ ] Stale closure? Use functional update
[ ] Missing dependency in useEffect?
[ ] Reading state outside of render/effect?
Symptom: Infinite re-renders
[ ] Calling setState in render body? (Move to event handler or useEffect)
[ ] useEffect without proper dependency array?
[ ] Object/array in dependency array recreated every render?
Symptom: State resets unexpectedly
[ ] Component unmounting/remounting? (Check key prop or conditional rendering)
[ ] Parent re-creating the child with a new key?
Key Takeaways
- useState returns
[value, setter]โ array destructuring lets you name them anything - Use lazy initialization for expensive computations โ pass a function, not a value
- Never mutate state โ always create new objects/arrays
- Use functional updates when new state depends on previous state
- State updates are batched โ multiple setters in one handler = one re-render
- Hooks must be called in the same order โ no conditionals, loops, or early returns before hooks
- Closures can capture stale state โ functional updates fix this
- Extract custom hooks when stateful logic is reused across components
- Prefer multiple useState for independent values, single object for related values
- Consider useReducer when state logic gets complex (more than 3-4 related state variables with intertwined update logic)