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.

· · 8 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 most common React hooks pitfalls are stale closures in useEffect, unnecessary useCallback and useMemo wrapping, and missing cleanup functions. Effects should synchronize with external systems, not replace component lifecycle methods.

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.

What Are the Most Common useState Mistakes?

useState looks simple. But two patterns burn developers regularly.

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 when the initial computation is non-trivial.

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.

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. It only helps in two situations: when the function is a dependency of another hook,

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, the memoization overhead often costs more than creating a new function.

When Is useMemo Worth the Overhead?

useMemo caches a computed value. Use it when the computation is genuinely expensive.

// 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]
);

Another use: 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

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. The visual hierarchy in UI design guide on Art of Styleframe covers how designers structure those states visually, which is useful context when you're deciding what boolean or enum your hook should expose.

// 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.