State, derivation and reducers
Using useState correctly, immutable updates, derived state and useReducer.
Updated
What state is
State = a component's memory: data that changes over time because of interaction (an input, a toggle, a counter). When the state changes, React re-renders the component with the new value.
Why not a plain variable? A local variable (let count = 0) resets on every render, and changing it doesn't trigger any screen update.
const [count, setCount] = useState(0)countthe current valuesetCountthe function that changes it and asks for a re-render0the initial value
Props vs state
| Props | State | |
|---|---|---|
| Comes from | the parent | the component itself |
| Can be changed inside the component | no | yes, through the setter |
| Role | configuration from outside | internal memory |
How the setter works
setCount(count + 1) // a new value
setCount(c => c + 1) // an updater — receives the most recent value- The setter does not change the variable immediately; it schedules a new render. A
console.log(count)right after it shows the old value. - React groups several updates (batching) into a single render.
- When the new value depends on the old one → an updater function:
setCount(c => c + 1)3 times gives +3;setCount(count + 1)3 times gives +1.
State is immutable
React compares the old state with the new one by reference. You replace objects and arrays, you don't change them — see References and immutability.
setUser({ ...user, name }) // ✓
setTodos(todos => [...todos, newTodo]) // ✓
user.name = name; setUser(user) // ✗ the same reference → no re-renderDerived state — don't store it
If a value can be computed from props or state, compute it while rendering. Don't keep it in another useState.
// ✗ two sources of truth + an effect that syncs them
const [todos, setTodos] = useState([])
const [doneCount, setDoneCount] = useState(0)
// ✓ a single source, the rest derived
const doneCount = todos.filter(t => t.done).lengthTruly expensive calculations → useMemo (or the React Compiler, which memoizes automatically).
Where to keep state — kinds and when
| Where | When | Example |
|---|---|---|
local useState |
only one component uses it | an input, an open dropdown |
| lifted into the parent (lift state up) | two siblings need it | a filter + a list |
the URL (?page=2) |
it has to be shareable and survive a reload | filters, pagination, the active tab |
| Context | many deep components, rare changes | the theme, the logged-in user, the language |
| a store (Zustand) | global, changed often | a shopping cart in an SPA |
| a server state cache | data from the server | Server state |
The rule: as low as possible, lift it only when needed.
useReducer — when the logic grows
When a piece of state has many ways to change, you centralize the logic in a reducer — a pure function (state, action) => newState:
function todosReducer(state, action) {
switch (action.type) {
case 'add': return [...state, action.todo]
case 'toggle': return state.map(t => (t.id === action.id ? { ...t, done: !t.done } : t))
case 'remove': return state.filter(t => t.id !== action.id)
default: return state
}
}
const [todos, dispatch] = useReducer(todosReducer, [])
dispatch({ type: 'add', todo })useState |
useReducer |
|---|---|
| 1–3 simple values | state with many transitions |
| logic in the handlers | logic in a single place, testable on its own |
Summary
- State = the component's memory; the setter asks for a re-render.
- An updater function when you depend on the old value; always new objects.
- Derive what you can compute; keep state as low as possible; the URL for what has to be shared.