Cloud Tech by Victor
FrontendIntermediate

React Hooks Deep Dive

How useState, useEffect, useMemo, and useCallback actually work under the hood, plus the dependency-array and stale-closure pitfalls that trip up most React code.

Updated 2026-07-154 min read

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

HookPurposeRe-runs when
useStateLocal component stateNever re-runs itself; triggers a re-render on set
useEffectSide effects after paintAny dependency in the array changes
useMemoMemoize an expensive computed valueAny dependency in the array changes
useCallbackMemoize a function referenceAny dependency in the array changes
useRefMutable value that does not trigger re-rendersNever, persists across renders untouched

Syntax

javascript
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

javascript
// 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>
}
javascript
// 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/useCallback everywhere by default, adding complexity without a measurable performance win.
  • Using useEffect for 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's Object.is bail-out applies (which it does for primitives that are actually equal), for objects/arrays, always create a new reference.
  • useMemo/useCallback only pay off when the memoized value feeds something that does its own comparison, a React.memo-wrapped child, or another hook's dependency array.
  • useRef is 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 of useState.

Best Practices

  • Let the ESLint react-hooks/exhaustive-deps rule 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/useCallback only after profiling shows a real cost, or when a memoized child/dependency genuinely needs referential stability.

Interview questions

Why does React require hooks to be called in the same order on every render?

React does not track hook state by name; it tracks it by call order in a linked list attached to the component's fiber. On each render, React walks that list and matches the nth useState call to the nth stored slot. If a hook is called conditionally (inside an if, or after an early return), the call order can shift between renders, and React ends up reading the wrong slot for a given hook; this is exactly why hooks cannot be called inside conditionals or loops.

What is a stale closure and how does it happen with useEffect?

A stale closure happens when a function captures a variable from a render that is no longer current, because the effect or callback was not re-created when that variable changed. Classic case: an effect with an empty dependency array reads a piece of state, since the effect only runs once, the function it closes over always sees the state value from the first render, not the latest one. The fix is to include the variable in the dependency array (or use a functional state update that does not need to read the outer value at all).

When should you reach for useMemo or useCallback, and when is it wasted effort?

They are worth it when a computation is genuinely expensive, or when the memoized value/function is a dependency of another hook, or a prop to a component wrapped in React.memo, in those cases, an unnecessary new reference on every render causes real extra work downstream. For cheap computations with no memoized consumer, useMemo/useCallback add overhead (the comparison itself, plus code complexity) without a measurable benefit, profile before reaching for them by default.

References

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement