React Hooks You'll Actually Use Every Day

A practical walkthrough of useState, useEffect, useCallback, useMemo, useRef, and useContext — with the gotchas that cause real bugs and when each hook genuinely pays off.

React Hooks You'll Actually Use Every Day

Most React tutorials tell you that useEffect runs after the component renders. What they don’t mention is that it also runs after every re-render — unless you explicitly tell it not to.

That one distinction explains a whole category of bugs: event listeners that attach twice because the effect fires on every state update, intervals that keep ticking long after the component is gone, fetch calls that try to update state on something already unmounted. The API looks simple on the surface. The sharp edges appear later.

This post is a companion to React Fundamentals: Components, Props, and State. That one covered components, props, and the basic form of useState. Here I’m going deeper on the hooks you’ll reach for in almost every real app — and spending time on the parts where people get tripped up.

Series: This post follows React Fundamentals: Components, Props, and State.

On this page

useState — the functional updater form

The React Fundamentals post covers the basics: useState(initialValue) returns a value and a setter, calling the setter schedules a re-render, and the new value shows up on the next render. There’s one form of setState that every React developer eventually needs, though, and it’s worth understanding before it bites you:

// This looks right, but breaks when called multiple times
function handleClick() {
  setCount(count + 1);
  setCount(count + 1);
  // Result: count goes up by 1, not 2
}

// This works correctly
function handleClick() {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
  // Result: count goes up by 2
}

When you call setCount(count + 1), you’re reading count from the current render snapshot. Both calls see the same snapshot — the one React captured at the start of this render. React’s documentation describes this explicitly: the setter function queues an update, but it doesn’t immediately change the variable in your running code. Passing a function (prev => prev + 1) tells React to apply the update against whatever the actual latest value is, not the one you captured.

This matters most inside useEffect — which is why I’m mentioning it here.

useEffect — the cleanup contract

useEffect has three distinct behaviours depending on what you pass as the second argument:

// Runs after every render — usually not what you want
useEffect(() => {
  document.title = 'Updated';
});

// Runs once, after the first render (mount)
useEffect(() => {
  logPageView();
}, []);

// Runs on mount AND whenever roomId changes
useEffect(() => {
  const conn = connect(roomId);
  return () => conn.close();
}, [roomId]);

According to the React documentation, every value from the component that you use inside an effect — props, state variables, variables declared in the component body — must appear in the dependency array. The eslint-plugin-react-hooks linter rule catches most of these automatically, and it’s worth trusting it.

The part most developers skip early on: the cleanup function. When your effect starts something — a subscription, an event listener, a timer — you need to stop it in the returned function.

// Classic memory leak: addEventListener never removed
useEffect(() => {
  window.addEventListener('resize', handleResize);
}, []);

// Correct
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

React calls the cleanup in two situations: before running the effect again (if dependencies changed), and when the component unmounts. Without cleanup, every re-render can leave behind a ghost — a listener still responding to events, a subscription still delivering data to a component that no longer exists in the tree.

The rule is simple: if you start something in useEffect, stop it in the cleanup. Every time.

The stale closure trap

Here’s the thing that surprises developers who thought they understood hooks. It connects directly to how closures work in JavaScript: a function captures the values from the scope where it was created, not the scope where it runs.

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // captures "count" from this render
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty array: effect runs only once

  return <h1>{count}</h1>;
}

The dependency array is [], so the effect runs once. The interval callback closes over count from the first render — which was 0. Every second it calls setCount(0 + 1). The counter never goes past 1.

Before you continue: what would happen if you added count to the dependency array instead? The effect would re-run every time count changed, clearing and restarting the interval on every tick. You’d swap one problem for another.

The clean solution is the functional updater form:

useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // no longer reads "count" at all
  }, 1000);
  return () => clearInterval(id);
}, []); // now genuinely safe with empty deps

The React docs describe this exact fix: when your effect needs to update state based on its current value, use the updater form. That way the effect doesn’t need to capture or depend on the state variable, and the stale closure problem disappears.

Understanding the JavaScript execution context and scope chain makes this click at a deeper level — the interval callback is a closure over a specific function call’s scope, not over a reactive variable that updates.

useCallback and useMemo — measure before you add

These two hooks get added everywhere as a reflex. They help in specific situations; in most other cases they just add noise.

useMemo caches the result of calling a function:

const sortedItems = useMemo(
  () => items.slice().sort((a, b) => a.name.localeCompare(b.name)),
  [items]
);

useCallback caches the function reference itself:

const handleSubmit = useCallback(
  (data) => post('/api/submit', data),
  []
);

The React documentation is direct about when these help: useCallback is only valuable when you pass the function to a component wrapped in React.memo, or when a memoized hook depends on the function as a dependency. A useCallback-wrapped function still gets created on every render — React just discards the new one if dependencies haven’t changed.

The pattern that actually works:

// Without React.memo on the child, useCallback on the parent does nothing
const ExpensiveChild = memo(function ExpensiveChild({ onSubmit }) {
  /* slow to render */
});

// Now useCallback keeps onSubmit reference stable across parent renders
const handleSubmit = useCallback((data) => {
  post('/product/' + productId + '/buy', { referrer, data });
}, [productId, referrer]);

return <ExpensiveChild onSubmit={handleSubmit} />;

memo and useCallback have to work together. memo tells the child to skip re-rendering if its props haven’t changed. useCallback ensures the function prop reference stays the same across renders. Without memo, there’s no skip-check. Without useCallback, the function reference changes every render regardless.

The honest advice: most component re-renders are fast enough that you won’t notice them. Reach for useMemo or useCallback after you’ve seen the problem with the React Developer Tools profiler, not as a preventive measure.

useRef — two jobs, one hook

useRef has two distinct uses that seem unrelated until you understand what they share: both give you a mutable value that does not trigger a re-render when you change it.

Job one: access a DOM node

function SearchInput() {
  const inputRef = useRef(null);

  function focusInput() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} type="text" />
      <button onClick={focusInput}>Focus</button>
    </>
  );
}

React sets inputRef.current to the DOM element after mount. Use it to call DOM methods that React doesn’t expose as props — focus(), scrollIntoView(), play() and pause() on a video element.

Job two: persist a value across renders without causing re-renders

function Stopwatch() {
  const [elapsed, setElapsed] = useState(0);
  const intervalRef = useRef(null);

  function start() {
    intervalRef.current = setInterval(
      () => setElapsed(t => t + 1),
      1000
    );
  }

  function stop() {
    clearInterval(intervalRef.current);
  }

  return (/* ... */);
}

intervalRef.current survives re-renders — unlike a local variable, which resets to its initial value every time the component function runs. But changing it doesn’t schedule a re-render — unlike useState. The React documentation puts the distinction cleanly: if you need to remember something between renders but it doesn’t affect what the user sees, use a ref. If it does affect the output, use state.

The same “persisting without triggering” property also solves the stale closure problem when you need to read a value inside an effect: store it in a ref, update the ref on every render, and the effect can always read the latest value without depending on it.

useContext — skip the prop chain

At some point your app has a value that a dozen components need — the current user, the active theme, a locale setting. Passing it as a prop through every intermediate component is called prop-drilling, and it’s the kind of thing that makes component trees hard to refactor.

Context solves it in three steps:

// 1. Create the context
const UserContext = createContext(null);

// 2. Provide the value near the top of the tree
function App() {
  const [user, setUser] = useState(null);
  return (
    <UserContext value={user}>
      <Layout />
    </UserContext>
  );
}

// 3. Read it anywhere inside the provider — no prop-threading needed
function Avatar() {
  const user = useContext(UserContext);
  return <img src={user?.avatarUrl} alt={user?.name ?? 'User'} />;
}

That’s the complete basic pattern. The React documentation describes this as “passing data deeply into the tree” — the value flows from the nearest matching Provider down to any component that calls useContext.

Two things worth knowing before you reach for it everywhere:

When the context value changes, every component that consumes that context re-renders. For values that change frequently — like a search query updated on each keystroke — this can cascade into unnecessary renders throughout the tree. The fix is to split into separate contexts by update frequency: one for the user object (changes rarely), another for the query (changes constantly).

Context isn’t a substitute for proper state colocation either. If a piece of data is only needed within one subtree, lifting it to that subtree’s common parent and passing it as props is usually clearer. Context earns its place when data genuinely needs to reach across many branches or deeply nested layers.

Know which tool solves the problem

A reference for the decision you’ll make repeatedly:

Situation Hook
Value that changes and must update the UI useState
Sync with something outside React — API, timer, browser event useEffect
DOM node you need to call methods on directly useRef
Value that persists across renders without causing re-renders useRef
Expensive calculation that should only re-run when inputs change useMemo
Stable function reference for a memo-wrapped child component useCallback
Data that many deeply-nested components need useContext

The stale closure trap in useEffect and the memo + useCallback pairing are where most of the confusion lives. Most bugs in this space come down to one of those two patterns — either an effect capturing an outdated value, or memoization that doesn’t work because the child isn’t wrapped in memo.

There’s one more level to this. When you find yourself writing the same useEffect + useState pattern for data fetching in every component, or the same event-listener setup across multiple files, that’s the signal for custom hooks. You pull the logic into a useXxx function, and every component that needs it calls one clean line. Custom hooks are where hooks become genuinely composable — and that’s exactly what the next post covers.

Sources