Overview
Hooks let function components hold state and side effects that used to require class components. Under the hood, each component instance keeps an ordered list of "hook slots" managed by React's fiber tree, useState and useEffect calls are matched to their slot by call order, not by name, which is why hooks must always run in the same order on every render. Understanding that mechanism explains most of the rules that otherwise feel arbitrary: no conditional hooks, dependency arrays that must be exhaustive, and why stale closures happen.
Quick Reference
| Hook | Purpose | Re-runs when |
|---|---|---|
useState | Local component state | Never re-runs itself; triggers a re-render on set |
useEffect | Side effects after paint | Any dependency in the array changes |
useMemo | Memoize an expensive computed value | Any dependency in the array changes |
useCallback | Memoize a function reference | Any dependency in the array changes |
useRef | Mutable value that does not trigger re-renders | Never, persists across renders untouched |
Syntax
const [state, setState] = useState(initialValue)
useEffect(() => {
// runs after paint when any dependency changes
return () => {
// optional cleanup, runs before the next effect or on unmount
}
}, [dependency1, dependency2])
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b])
const memoizedFn = useCallback((x) => doSomething(a, x), [a])Examples
// Stale closure bug: effect only runs once, so `count` inside it is
// permanently the value from the first render.
function BuggyCounter() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
console.log(count) // always logs 0
}, 1000)
return () => clearInterval(id)
}, []) // missing `count` in the dependency array
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}// Fixed: a functional update never needs to read the outer `count`,
// so the effect can safely keep an empty dependency array.
function FixedCounter() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1) // reads the latest state via the updater function
}, 1000)
return () => clearInterval(id)
}, [])
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>
}Visual Diagram
Common Mistakes
- Calling hooks conditionally or after an early
return; this breaks React's slot-matching and produces confusing, order-dependent bugs. - Leaving a dependency out of
useEffect's array to "stop it from re-running"; this is almost always a stale-closure bug in disguise, not a real fix. - Mutating state directly (
state.push(x)) instead of creating a new reference, React compares by reference, so a mutation in place will not trigger a re-render. - Overusing
useMemo/useCallbackeverywhere by default, adding complexity without a measurable performance win. - Using
useEffectfor something derivable during render, if a value can be computed directly from props/state, computing it in the render body is simpler and avoids an extra render cycle.
Performance
useState's setter triggers a re-render even if you set the same value as before, unless React'sObject.isbail-out applies (which it does for primitives that are actually equal), for objects/arrays, always create a new reference.useMemo/useCallbackonly pay off when the memoized value feeds something that does its own comparison, aReact.memo-wrapped child, or another hook's dependency array.useRefis the escape hatch for values that need to persist across renders without causing one, timers, previous-value tracking, and DOM node references all belong there instead ofuseState.
Best Practices
- Let the ESLint
react-hooks/exhaustive-depsrule guide dependency arrays instead of fighting it; a missing dependency is usually a real bug. - Prefer functional state updates (
setCount(c => c + 1)) whenever the new state depends on the old one, to avoid needing that value in a dependency array at all. - Keep effects narrowly scoped to one concern each, rather than one large effect handling several unrelated side effects.
- Reach for
useMemo/useCallbackonly after profiling shows a real cost, or when a memoized child/dependency genuinely needs referential stability.