useState: State Management
useState: State Management
State is what makes React components dynamic. Without state, components are static — they render once and never change. With state, components respond to user interaction, API responses, and time-based changes.
useState is the hook you'll use in nearly every component you write.
What State Is (and What It Isn't)
State is data that:
- Belongs to a specific component
- Can change over time
- Causes the component to re-render when it changes
Not state:
- Data that never changes → use a constant or prop
- Data derived from other state/props → calculate it during render
- Data shared across many components → lift it up or use Context
Basic useState
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0); // [currentValue, setterFunction]
// ^— initial value
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>−</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
useState returns an array with exactly two items:
- The current state value
- A setter function that updates the value AND triggers a re-render
The Golden Rule: Never Mutate State
React detects state changes by reference equality. If you mutate the current value directly, React won't know it changed.
// WRONG — mutating state
const [user, setUser] = useState({ name: "Alice", age: 30 });
function birthday() {
user.age++; // Mutates the existing object
setUser(user); // React sees the same reference — no re-render!
}
// RIGHT — always create a new value
function birthday() {
setUser({ ...user, age: user.age + 1 }); // New object = new reference
}
Same rule for arrays:
// WRONG
const [items, setItems] = useState(["a", "b", "c"]);
items.push("d"); // Mutates — won't trigger re-render
// RIGHT
setItems([...items, "d"]); // Add item
setItems(items.filter(i => i !== "b")); // Remove item
setItems(items.map(i => i === "a" ? "A" : i)); // Update item
Functional Updates
When new state depends on previous state, use a function:
// This can have race conditions with multiple rapid clicks
setCount(count + 1);
// This is always correct — uses the latest state
setCount(prevCount => prevCount + 1);
The function form guarantees you're working with the most recent state value:
function Counter() {
const [count, setCount] = useState(0);
function handleTripleIncrement() {
// These all run before re-render — count is still 0 for all three
setCount(count + 1); // → 1
setCount(count + 1); // → 1 again! count was still 0
setCount(count + 1); // → 1 again!
// Use functional update to correctly chain:
setCount(prev => prev + 1); // → 1
setCount(prev => prev + 1); // → 2
setCount(prev => prev + 1); // → 3
}
}
State with Objects
function UserForm() {
const [form, setForm] = useState({
name: '',
email: '',
role: 'user',
});
function handleChange(field, value) {
setForm(prev => ({
...prev, // Keep all existing fields
[field]: value // Update only the changed field
}));
}
return (
<form>
<input
value={form.name}
onChange={e => handleChange('name', e.target.value)}
/>
<input
value={form.email}
onChange={e => handleChange('email', e.target.value)}
/>
<select
value={form.role}
onChange={e => handleChange('role', e.target.value)}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</form>
);
}
Practical Example: Todo List
function TodoApp() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
function addTodo() {
if (!input.trim()) return;
setTodos(prev => [
...prev,
{ id: Date.now(), text: input.trim(), completed: false }
]);
setInput('');
}
function toggleTodo(id) {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
function deleteTodo(id) {
setTodos(prev => prev.filter(todo => todo.id !== id));
}
return (
<div>
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTodo()}
placeholder="Add a todo..."
/>
<button onClick={addTodo}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
onClick={() => toggleTodo(todo.id)}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>×</button>
</li>
))}
</ul>
<p>{todos.filter(t => !t.completed).length} remaining</p>
</div>
);
}
Lazy Initial State
If the initial state is expensive to compute, pass a function:
// This runs getExpensiveValue() on every render — wasteful
const [data, setData] = useState(getExpensiveValue());
// This only runs once — React calls the function on mount only
const [data, setData] = useState(() => getExpensiveValue());
// Common use: read from localStorage
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') ?? 'light';
});
Common useState Mistakes
// Mistake 1: State too low — should be lifted
// ❌ Both siblings need the count — don't store it in only one
function Parent() {
return (
<>
<CounterDisplay /> {/* Can't see the count */}
<CounterButton /> {/* Has the count state */}
</>
);
}
// ✅ Lift state to parent
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<CounterDisplay count={count} />
<CounterButton onIncrement={() => setCount(c => c + 1)} />
</>
);
}
// Mistake 2: Redundant state (derived data)
// ❌ Don't store what you can calculate
const [firstName, setFirstName] = useState('Alice');
const [lastName, setLastName] = useState('Smith');
const [fullName, setFullName] = useState('Alice Smith'); // Redundant!
// ✅ Calculate it
const fullName = `${firstName} ${lastName}`; // No state needed
Next lesson: useEffect — managing side effects like data fetching, subscriptions, and timers.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises