React Hooks Part 1: Managing State with useState
Learn how useState lets functional components hold and update local state. We cover the basics, the callback pattern to avoid race conditions, and object/array state management.
React Hooks: A Complete Guide
A four-part deep-dive into React Hooks — from managing state with useState to squeezing performance out of useCallback and useMemo. Each article is standalone yet builds on the previous, giving you both quick reference and progressive mastery.
- 1 React Hooks Part 1: Managing State with useState
- 2 React Hooks Part 2: Side Effects with useEffect
- 3 React Hooks Part 3: useRef and useReducer
- 4 React Hooks Part 4: useCallback, useMemo, and Custom Hooks
- 5 React Hooks Part 5: Sharing State with useContext
Table of Contents
What is useState?
Before Hooks, only class components could hold local state. The useState hook changes that — any functional component can now own state with a single import.
import { useState } from 'react'
useState accepts one argument — the initial state value — and returns a two-element array: the current value and a setter function.
const [count, setCount] = useState(0)
A minimal counter
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>−</button>
</div>
)
}
Every time you call setCount, React schedules a re-render and the component displays the new value.
The callback pattern — avoid stale closures
If your new value depends on the previous one, don't read count directly — pass a callback instead:
// Bad: can produce wrong results under rapid updates
setCount(count + 1)
// Good: always receives the latest committed value
setCount(prev => prev + 1)
React batches state updates in event handlers, so the first form might reference a stale snapshot of count. The callback form is immune to this.
Object state
When your state is an object you must spread the existing fields — useState does not shallow-merge like class-based this.setState.
const [user, setUser] = useState({ name: '', email: '' })
const handleName = e => setUser(prev => ({ ...prev, name: e.target.value }))
const handleEmail = e => setUser(prev => ({ ...prev, email: e.target.value }))
Forgetting the spread silently drops every field you didn't mention — the single most common useState bug.
Array state
Same principle: never mutate in place.
const [items, setItems] = useState([])
const addItem = item => setItems(prev => [...prev, item])
const removeItem = id => setItems(prev => prev.filter(i => i.id !== id))
Lazy initialisation
If your initial value is expensive to compute, pass a function so it only runs once:
// Runs parseHeavyData() on every render
const [data, setData] = useState(parseHeavyData(rawInput))
// Function is called only during the first render
const [data, setData] = useState(() => parseHeavyData(rawInput))
Key takeaways
- Destructure into
[value, setValue]— naming is up to you. - Use the callback form (
prev => …) whenever new state depends on the old one. - Always spread when updating object/array state.
- Pass a function for expensive initial values.
Next up in the series: useEffect — running side effects after render.