Skip to content

React Hooks: State, Effects, and Dependency Arrays

React hooks best practices for production: fix stale closures, dependency array bugs, and over-used useCallback and useMemo with real code examples.

· · 12 min read

Updated: July 12, 2026

React hooks code with useState and useEffect shown in a dark-theme code editor

Quick Take

Stale closures, missing dependency arrays, and over-memoization account for most React performance bugs in production. Fix these three patterns and you'll eliminate the majority of hooks-related issues without touching your component architecture.

Quick take: The three React hooks bugs that reach production most often are stale closures in useEffect, missing cleanup on subscriptions, and reflexive useCallback or useMemo wrapping that costs more than it saves. Effects synchronize with external systems; they are not lifecycle methods in disguise. eslint-plugin-react-hooks catches the first two.

The best practices below are not style preferences, they are the rules that decide whether a component re-renders correctly. React hooks follow two non-negotiable rules: call them only at the top level (never inside conditions, loops, or nested functions), and only from React function components or other custom hooks. The eslint-plugin-react-hooks package enforces both automatically. Beyond the rules, the most common production mistakes are stale closures in useEffect (capturing a variable at setup time while it changes later), missing cleanup functions in effects that subscribe to external data, and over-applying useCallback and useMemo where memoization overhead exceeds the savings. useEffect is for managing side effects (synchronizing with external systems, network requests, subscriptions, timers), not for replacing component lifecycle methods. A well-formed dependency array is what keeps effects from running too often or not enough. Use the functional update form of setState whenever new state depends on the previous value: setCount(prev => prev + 1) instead of setCount(count + 1). In React 18 strict mode, effects run twice in development intentionally to surface missing cleanup bugs.

The same hooks bugs keep showing up: effects running every render, callbacks breaking memoization, state updates triggering infinite loops. I've hit most of these in production.

Three terms get used loosely in hook discussions, and the looseness is where the confusion starts:

  • Stale closure is a function that captured a variable during an earlier render and still reads that old value, because JavaScript closed over the binding rather than tracking the current one.
  • Referential stability means a value keeps the same identity across renders, which is what React.memo and dependency arrays compare on.
  • Cleanup function is whatever an effect returns; React calls it before the next run and on unmount, and its absence is what leaks subscriptions.

When an effect misbehaves, work through it in this order rather than adding dependencies until the warning stops:

  1. Decide what external system the effect synchronizes with. If the answer is "none", it probably shouldn't be an effect at all.
  2. List every value from the render scope the effect body reads. That list is the dependency array, with no exceptions negotiated.
  3. Ask whether re-running on each of those values is correct. If not, the problem is the effect's shape, not the array.
  4. Return a cleanup function if the effect subscribed, opened, or scheduled anything.

What Are the Most Common useState Mistakes?

useState has the smallest API surface of any React hook and still produces two bugs I see in almost every code review: reading the previous value directly instead of through the updater function, and computing expensive initial state on every render when it should run once.

Using previous state incorrectly:

// Wrong - reads stale count from the closure
const [count, setCount] = useState(0);

function handleClick() {
  setCount(count + 1);
  setCount(count + 1); // still reads the original count
}
// After two clicks: count is 1, not 2

// Correct - always reads the latest state
function handleClick() {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
}
// After two clicks: count is 2

Use the functional update form whenever the new state depends on the previous value. Always.

Expensive initial state running on every render:

// Wrong - parseStoredUser() runs on every render
const [user, setUser] = useState(parseStoredUser(localStorage.getItem('user')));

// Correct - lazy initializer runs only once
const [user, setUser] = useState(() => parseStoredUser(localStorage.getItem('user')));

The lazy initializer matters whenever the initial computation is non-trivial. In the first version, parseStoredUser runs on every single render and React throws the result away every time after the first, so a component that re-renders sixty times during a drag interaction parses localStorage sixty times. Passing a function instead means React calls it once, on mount.

Both bugs share a root cause: useState looks like a plain assignment, so people reason about it as one. It isn't. The value you read is a snapshot from the render that produced it, and the argument you pass is evaluated by JavaScript before React ever sees it. Keep those two facts in mind and this whole category of bug disappears.

A code editor showing React JSX source next to a running app rendering the React logo
Photo by Lautaro Andreani on Unsplash

How Do You Write useEffect Without the Stale Closure Trap?

Stale closures are the most common source of side effects bugs in React. They happen when an effect captures a value at setup time and that value changes later, but the effect's dependency array doesn't include it.

// Bug: interval always logs the original count
useEffect(() => {
  const id = setInterval(() => {
    console.log(count); // stale - always 0
  }, 1000);
  return () => clearInterval(id);
}, []); // missing count in deps

// Fix: include count in deps (but this resets the interval on each change)
useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(id);
}, [count]);

// Better fix: use a ref to read latest value without restarting
const countRef = useRef(count);
useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const id = setInterval(() => {
    console.log(countRef.current); // always fresh
  }, 1000);
  return () => clearInterval(id);
}, []);

The ref pattern works well for event handlers inside effects that need fresh values without restarting. The key insight: a ref doesn't trigger a re-render when it changes, so reading countRef.current inside the interval doesn't need to be in the dependency array.

Picking between the three versions is a question of what the effect is actually for. If the effect's job depends on the value, include it in the deps and accept the teardown. An interval that logs a count is arguably fine restarting once per second. A WebSocket connection that reconnects on every keystroke is not.

Never silence the lint rule to make the warning go away. exhaustive-deps has been right about roughly nine out of ten warnings I have investigated, and the tenth was a design problem the rule was pointing at rather than a false positive. React 19's useEffectEvent is the sanctioned answer for the "read the latest value, don't re-subscribe" case, and it replaces most hand-rolled ref plumbing.

Always Return Cleanup

Effects that subscribe to things must clean up:

useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

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

This handles strict mode's double-invocation correctly, the second call aborts the first fetch before it settles.

When Does useCallback Actually Help?

useCallback memoizes a function reference across renders, and it earns its keep in exactly two situations. The first is when the function is itself a dependency of another hook, where an unstable reference makes the dependent effect re-run on every render:

const fetchUser = useCallback(async (id: string) => {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}, []); // stable reference

useEffect(() => {
  fetchUser(userId).then(setUser);
}, [fetchUser, userId]); // fetchUser won't trigger re-run

or when passing it to a React.memo-wrapped child.

const handleDelete = useCallback((id: string) => {
  setItems(prev => prev.filter(item => item.id !== id));
}, []); // stable - won't cause MemoizedList to re-render

return <MemoizedList items={items} onDelete={handleDelete} />;

Don't wrap every function in useCallback by default. Creating a function in JavaScript is cheap, roughly a few nanoseconds; useCallback adds a dependency array comparison plus a slot in the hook list on every render, and it does that whether or not anything downstream benefits.

The test I apply is simple. If removing the useCallback would change behaviour or trigger a measurable re-render, keep it. If the only thing it changes is that a function identity stays stable while nothing observes that identity, delete it. React 19's compiler makes this decision for you in most components, which is a good argument for upgrading before you spend an afternoon hand-tuning memoization.

When Is useMemo Worth the Overhead?

useMemo caches a computed value between renders, and the bar for using it is higher than most codebases assume: the computation has to be genuinely expensive relative to the comparison cost of its dependency array.

// Not worth it - string concatenation is cheap
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

// Worth it - filtering 5000 items is expensive
const filteredProducts = useMemo(
  () =>
    products
      .filter(p => p.category === selectedCategory && p.price <= maxPrice)
      .sort((a, b) => a.name.localeCompare(b.name)),
  [products, selectedCategory, maxPrice]
);

The dependency comparison itself isn't free. React runs a shallow equality check over the array on every render, so memoizing a template literal costs more than recomputing it. Filtering and sorting five thousand products is a different story, and that is roughly where the crossover sits in my profiling: a few thousand array items, or any computation you can see in a flame graph.

A second, better-justified use is referential stability for objects passed to memoized children.

// Without useMemo: new object on every render breaks child memoization
const config = useMemo(
  () => ({ threshold: 0.1, rootMargin: '200px' }),
  [] // created once
);

return <IntersectionObserverChild config={config} />;
A developer typing on two laptops running code at a warm, cozy home workspace
Photo by Getty Images on Unsplash

useCallback vs useMemo: When Each Is Worth It

useCallbackuseMemo
MemoizesA function referenceA computed value
Worth using whenPassed to React.memo child, or a dependency of another hookGenuinely expensive computation, or referential stability for objects
Not worth it whenWrapped by default on every functionCheap operations like string concatenation
React 19 Compiler impactMostly unnecessary, compiler inserts it at build timeMostly unnecessary, compiler inserts it at build time

The bottom row is the one worth reading twice. The React Compiler, stable since React 19, inserts memoization at build time by analysing which values actually flow into which renders. It is better at that analysis than we are, because it can see the whole component rather than the one call site in front of you.

That changes the advice rather than deleting it. On a React 19 codebase with the compiler enabled, reach for these hooks only when profiling shows a specific problem the compiler didn't solve, typically a value crossing a context boundary or an object handed to a third-party library that does its own identity checks. On React 18 and earlier, the two rules above still apply in full: memoize when something downstream observes the identity, and leave it alone otherwise.

How Do You Write Good Custom Hooks?

Custom hooks are one of the cleanest state management patterns React gives you. They extract stateful logic so it's testable and shareable. If the same useState/useEffect combo appears in two components, pull it into a hook. A hook is also what decides which visual state the user sees at any point (loading, error, success, empty), so the component hierarchy your designer built in Figma maps directly to your hook's state shape.

// Extracted custom hook
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);

    fetch(url, { signal: controller.signal })
      .then(r => r.json())
      .then((d: T) => { setData(d); setLoading(false); })
      .catch(err => {
        if (err.name !== 'AbortError') { setError(err); setLoading(false); }
      });

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

  return { data, loading, error };
}

// Clean call site
function ProductList() {
  const { data, loading, error } = useFetch<Product[]>('/api/products');
}

The <T> generic on useFetch is what lets useFetch<Product[]>(...) infer data as Product[] | null rather than unknown. If that syntax is unfamiliar, our TypeScript generics explained guide walks through the constraints, defaults, and conditional patterns that make typed custom hooks ergonomic instead of ceremonial.

Rules of Hooks

Two rules, no exceptions: call hooks at the top level of your React component (never in loops, conditions, or nested functions), and only from function components or other hooks. Built-in hooks like useState and useEffect rely on a stable call order between renders. React breaks in surprising ways the moment you smuggle a hook into a conditional branch. The eslint-plugin-react-hooks package enforces both, install it. The useState hook returns a state variable plus its setter. The useEffect hook runs side effects after render: that's where you fetch data, set up subscriptions, or sync with non-React systems. Understanding component lifecycle helps here: effects run after the browser paints, and cleanup runs before the next effect fires or when the component unmounts.

What About React 19?

The React 19 Compiler changes the calculus on manual memoization. If your codebase adopts it, you can drop most useCallback and useMemo calls because the compiler inserts memoization at build time. The advice above still matters though: understanding the pitfalls teaches you when the compiler cannot help (impure effects, missing cleanups, stale closures). Treat the compiler as a safety net, not a replacement for thinking about dependencies.

One more thing I learned the hard way: when migrating a large app, don't flip every component to the compiler at once. Run the compiler on one feature folder first, measure render counts with the React DevTools profiler, then expand. I did a portfolio-wide flip on a 40-component dashboard and spent two hours tracing a regression that turned out to be a missed hook dependency the old manual memoization was masking.

  • React 19 guide - React 19's React Compiler automatically memoizes components and hooks, reducing the need for manual useCallback and useMemo
  • TypeScript utility types - use Pick, Omit, and Partial to type your hook return values and state objects correctly
  • React Hook Form vs Formik - form state is one of the trickiest hook patterns; React Hook Form's uncontrolled approach sidesteps many of the useEffect pitfalls covered here
  • Zustand vs Jotai - when local hook state isn't enough, these are the two libraries most React teams reach for next

Frequently Asked Questions

Why does useEffect run twice in development?
In React 18 strict mode, effects run twice intentionally to surface cleanup bugs. Your cleanup function should fully reverse the effect setup. If your app breaks on double-run, there's a missing cleanup.
When should I use useCallback?
Use useCallback when passing a function to a component wrapped in React.memo, or as a dependency of another hook like useEffect. Don't add it by default - the memoization itself has a cost.
Is useMemo always worth it?
No. useMemo adds overhead from the dependency comparison on every render. It's only worthwhile for genuinely expensive computations (sorting large arrays, heavy filtering) or for referential stability of objects passed to memoized children.
What is a custom hook and when should I write one?
A custom hook is any function starting with 'use' that calls other hooks. Write one when the same stateful logic appears in two or more components, or when a single component's effect logic becomes hard to follow.