State with hooks
useState and useReducer, batched updates, immutable updates for objects and arrays, and deriving values instead of storing them.
useState and the rules of updates
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(c => c + 1)}>+1 (functional)</button>
<button onClick={() => setCount(0)}>reset</button>
<p>{count}</p>
</>
)
}- State updates are asynchronous and batched: after
setCount(count + 1)twice in one handler,countis still the old value in both calls. Use the updater formsetCount(c => c + 1). - Hooks must be called at the top level of a component or another hook — never inside a condition, a loop or an event handler.
- Use one
useStateper independent value. Combining unrelated fields into one object makes updates verbose and re-renders coarse. useStatewith a function argument (useState(() => expensive())) runs the initialiser once; passing the call result runs it on every render.
// immutable updates: build a new object / array, never mutate the old one
const [form, setForm] = useState({ email: '', name: '' })
setForm(prev => ({ ...prev, email: '[email protected]' })) // object merge
const [tags, setTags] = useState([])
setTags(prev => [...prev, 'new']) // append
setTags(prev => prev.filter(t => t !== 'new')) // remove
setTags(prev => prev.map(t => (t === 'x' ? 'y' : t))) // replaceuseReducer for related fields
import { useReducer } from 'react'
function reducer(state, action) {
switch (action.type) {
case 'add':
return { ...state, items: [...state.items, action.item] }
case 'remove':
return { ...state, items: state.items.filter(i => i.id !== action.id) }
case 'clear':
return { ...state, items: [] }
default:
throw new Error('unknown action: ' + action.type)
}
}
const [cart, dispatch] = useReducer(reducer, { items: [] })
dispatch({ type: 'add', item: product })- Reach for
useReducerwhen several values change together or the next state depends on the previous in more than one way. - A reducer must be pure: same input, same output, no fetching, no logging to a server, no mutating its arguments.
- Throwing on an unknown action turns a silent typo into an immediate error — worth the extra line.
- Context plus a reducer is the usual lightweight alternative to a state library for medium-sized apps.
Deriving instead of storing
import { useState, useMemo } from 'react'
function ProductTable({ products, query }) {
const [sort, setSort] = useState('name')
// derived at render time - no state, nothing to keep in sync
const visible = useMemo(() => {
const q = query.toLowerCase()
return products
.filter(p => p.name.toLowerCase().includes(q))
.sort((a, b) => String(a[sort]).localeCompare(String(b[sort])))
}, [products, query, sort])
return <Table rows={visible} onSort={setSort} />
}| Situation | Tool |
|---|---|
| A value can be computed from props or state | Plain expression in the render body |
| The computation is expensive and inputs rarely change | useMemo |
| A stable function identity is needed by a memoised child | useCallback |
| A value must survive renders without triggering one | useRef |
| State changes must be synchronised with something outside React | useEffect |
⚠️
Storing derived data in state is the most common source of inconsistent UI — two copies that drift apart. If a value can be computed from props and state, compute it during render; add
useMemo only when profiling shows the work is actually expensive.FAQ
Does setState merge objects like the old class API?
No. The hook replaces the value entirely, so spread the previous object yourself:
setForm(prev => ({ ...prev, name })).Is useMemo required for performance?
Rarely. It costs memory and comparison work on every render. Measure, then memoise the specific expensive computation rather than sprinkling it everywhere.
Related
Components and props Effects and data fetching
Last refreshed 2026-09-18.