March 26, 20266 min read

React Hooks: What Each One Does and When It Matters

A practical walkthrough of useState, useEffect, useRef, useContext, useMemo, and useCallback. When to reach for each hook, and the mistakes that cause infinite re-renders and stale data.

react hooks javascript frontend
Ad 336x280

React hooks have been around since 2019, but I still see the same mistakes in code reviews constantly. Not because hooks are hard to use -- the API is small -- but because the mental model behind them is different from what most people expect.

Here's each hook, when it actually matters, and the traps worth knowing about.

useState: Your Component's Memory

const [count, setCount] = useState(0);
useState gives your component a value that persists across re-renders. When you call setCount, React re-renders the component with the new value.

The thing people miss: state updates are asynchronous and batched. This doesn't work the way it looks:

function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  // count is still the old value here
  // you get one increment, not two
}

Both calls see the same count value. Use the functional form when the next state depends on the previous:

setCount(prev => prev + 1);
setCount(prev => prev + 1);
// now you get two increments
When it matters: Any value that should trigger a re-render when it changes. Form inputs, toggle states, fetched data, selected items. When it doesn't: Values that change but shouldn't cause a re-render. That's what useRef is for.

useEffect: Synchronizing With the Outside World

useEffect(() => {
  const controller = new AbortController();
  fetch(/api/users/${userId}, { signal: controller.signal })
    .then(res => res.json())
    .then(data => setUser(data));

return () => controller.abort();
}, [userId]);

useEffect runs after the render is painted to the screen. It's for side effects -- things that reach outside React: API calls, subscriptions, DOM manipulation, timers.

The dependency array ([userId]) tells React when to re-run the effect. Change userId, effect re-runs. Empty array [] means "run once on mount." No array means "run after every render" (almost never what you want).

The Infinite Re-render Trap

This is the single most common React hooks bug:

// INFINITE LOOP
useEffect(() => {
  setData(transformData(rawData));
}, [rawData]);
// if transformData returns a new object reference every time,
// and rawData is derived from data... boom

Or the classic version:

useEffect(() => {
  fetchUsers().then(setUsers);
}); // no dependency array = runs every render = infinite loop

Always include the dependency array. Always.

Cleanup Functions

The function you return from useEffect runs before the next effect and on unmount. Use it to cancel subscriptions, abort fetches, clear timers:

useEffect(() => {
  const interval = setInterval(() => tick(), 1000);
  return () => clearInterval(interval);
}, []);

If you skip cleanup, you get memory leaks and "Can't perform a React state update on an unmounted component" warnings.

useRef: A Mutable Box That Doesn't Re-render

const inputRef = useRef(null);
const renderCount = useRef(0);

renderCount.current += 1; // mutating this does NOT trigger re-render

useRef gives you a .current property you can read and write freely. Changes to it never cause re-renders. Two main uses:

  1. DOM references: then inputRef.current.focus()
  2. Mutable values that shouldn't trigger renders: Previous state values, timer IDs, counters, flags
A pattern I use constantly -- storing the latest callback without re-subscribing:
const savedCallback = useRef(onTick);
savedCallback.current = onTick; // update every render

useEffect(() => {
const id = setInterval(() => savedCallback.current(), 1000);
return () => clearInterval(id);
}, []); // never re-subscribes, but always calls the latest callback

useContext: Avoiding Prop Drilling

const ThemeContext = createContext("light");

function App() {
return (
<ThemeContext.Provider value="dark">
<Dashboard />
</ThemeContext.Provider>
);
}

function SomeDeepChild() {
const theme = useContext(ThemeContext);
// "dark" -- no prop drilling needed
}

useContext reads the nearest provider above the component in the tree. It's great for truly global values: theme, current user, locale.

The catch: Every component that calls useContext(X) re-renders when the context value changes. If you stuff a big object into context and update one property, every consumer re-renders. Keep context values small or split them into separate contexts.

useMemo: Caching Expensive Calculations

const sortedItems = useMemo(() => {
  return items.sort((a, b) => a.price - b.price);
}, [items]);

useMemo caches the result of a computation and only recalculates when its dependencies change. Sounds useful, right? Here's the thing: most of the time, you don't need it.

React re-renders are fast. Sorting 50 items takes microseconds. The overhead of useMemo itself (comparing dependencies, storing the cached value) can actually be more expensive than just recalculating.

When it matters: Expensive computations (sorting/filtering thousands of items), creating objects passed as props to memoized children (React.memo), values used in other hooks' dependency arrays. When to skip it: Simple derivations, string concatenation, basic math, small arrays. If you're not sure whether you need it, you don't need it.

useCallback: Memoizing Functions

const handleSubmit = useCallback((formData) => {
  saveUser(formData);
}, [saveUser]);

useCallback is useMemo for functions. It returns the same function reference as long as the dependencies haven't changed.

The only time this matters is when you're passing the function as a prop to a child wrapped in React.memo. Without useCallback, the child gets a new function reference every render and re-renders unnecessarily.

If the child isn't memoized, useCallback does literally nothing useful. I see it sprinkled across codebases "just in case" -- that's cargo culting, not optimization.

The Stale Closure Problem

This is the trickiest hooks concept. Closures capture values at the time they're created:

function Timer() {
  const [count, setCount] = useState(0);

useEffect(() => {
const id = setInterval(() => {
console.log(count); // always 0! stale closure
setCount(count + 1); // always sets to 1
}, 1000);
return () => clearInterval(id);
}, []); // empty deps = effect captures initial count forever
}

The interval callback closes over the initial count value (0) and never sees updates. Fix it with the functional updater:

setCount(prev => prev + 1); // always correct

Or include count in the dependency array (but then the interval restarts every second, which may not be what you want).

Building the Intuition

Reading about hooks helps, but the patterns only stick once you've hit the bugs yourself -- the infinite re-render, the stale closure, the useMemo that made things slower. CodeUp has React challenges designed around exactly these scenarios, so you can break things in a sandbox instead of production.

The core mental model: hooks are called in order, every render, and they connect your component to values and effects that persist across those renders. Once that clicks, the individual hooks are just variations on that theme.

Ad 728x90