Skip to content
⚠️ This article was written in 2019. Some content may be outdated.

The Complete Guide to useEffect: The Dependency Array Pitfall

After using React Hooks for a month, I found that useEffect is the easiest place to get burned. The dependency array especially will lead to bugs if you don't understand the underlying principles.

Start with the Mental Model ​

useEffect is essentially about "synchronization" — keeping side effects in sync with React rendering.

javascript
// Mental model:
// After each render, React checks whether dependencies have changed
// If they have, it first runs the previous cleanup function, then runs the new effect

function MyComponent({ id }) {
  useEffect(() => {
    console.log("effect ran, id =", id);
    return () => {
      console.log("cleanup, id =", id); // the previous id
    };
  }, [id]);
}

// Render id=1 → effect runs (id=1)
// Render id=2 → cleanup (id=1) → effect runs (id=2)
// Unmount    → cleanup (id=2)

Common Mistake 1: Missing Dependencies ​

javascript
// ❌ Wrong: missing the userId dependency
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, []); // [] means run once, but won't re-fetch when userId changes!

  return <div>{user?.name}</div>;
}

// ✅ Correct: declare all dependencies
useEffect(() => {
  fetchUser(userId).then(setUser);
}, [userId]); // re-fetches when userId changes

After installing eslint-plugin-react-hooks, the exhaustive-deps rule will automatically warn about missing dependencies.

Common Mistake 2: Infinite Loops ​

javascript
// ❌ Modifying a state that is also a dependency inside the effect
function ProblematicComponent() {
  const [data, setData] = useState([]);

  useEffect(() => {
    processData(data); // reads data
    setData([...data, newItem]); // modifies data
  }, [data]); // data changes → reruns → data changes again → infinite loop!
}

// ✅ Use a ref or functional update
function FixedComponent() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount((c) => c + 1); // functional update, no dependency on count
    }, 1000);
    return () => clearInterval(timer);
  }, []); // [] is valid because we no longer depend on count
}

useCallback and useMemo ​

When effects depend on functions, problems easily arise:

javascript
// ❌ Creates a new fetchUser function reference on every render
function Parent({ userId }) {
  const fetchUser = () => fetch(`/api/user/${userId}`); // new on every render

  useEffect(() => {
    fetchUser();
  }, [fetchUser]); // runs on every render!
}

// ✅ useCallback caches the function reference
function Parent({ userId }) {
  const fetchUser = useCallback(() => {
    return fetch(`/api/user/${userId}`);
  }, [userId]); // only creates a new function when userId changes

  useEffect(() => {
    fetchUser();
  }, [fetchUser]); // fetchUser is stable, no infinite execution
}
javascript
// useMemo: cache computed results (avoid recalculating on every render)
function ProductList({ products, category }) {
  // Only re-filters when products or category changes
  const filtered = useMemo(
    () => products.filter((p) => p.category === category),
    [products, category],
  );

  return (
    <ul>
      {filtered.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}

MIT Licensed