React8 min read

React useEffect Hook — When and How to Use

Master React useEffect hook. Learn side effects, dependency arrays, cleanup functions, common pitfalls, and when to use useEffect vs other hooks.

reacthooksuseEffectside-effects

React useEffect Hook — When and How to Use

useEffect is how you run side effects in React functional components. Side effects are things that happen "outside" the component — API calls, subscriptions, DOM manipulation, timers.

Basic Syntax

import { useEffect } from "react";

useEffect(() => {
  // Side effect code here
  console.log("Component rendered!");
}, []);

The function you pass is the effect. The array at the end is the dependency array.

The Dependency Array

The dependency array controls WHEN the effect runs:

// Runs after EVERY render
useEffect(() => {
  console.log("Runs every time");
});

// Runs only on FIRST render (mount)
useEffect(() => {
  console.log("Runs once");
}, []);

// Runs when `count` changes
useEffect(() => {
  console.log("Count changed to:", count);
}, [count]);

// Runs when `name` OR `age` changes
useEffect(() => {
  console.log("Name or age changed");
}, [name, age]);

Think of it like this: "Run this effect whenever these values change."

Cleanup Function

The function you return from useEffect is the cleanup — it runs before the next effect or when the component unmounts:

useEffect(() => {
  const handleResize = () => console.log(window.innerWidth);
  window.addEventListener("resize", handleResize);
  
  // Cleanup — remove the listener
  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

Without cleanup, you'd keep adding event listeners on every render — a memory leak.

When Cleanup Runs

  1. Before the next effect (if dependencies changed)
  2. On unmount (component removed from DOM)
useEffect(() => {
  console.log(`Subscribed to user ${userId}`);
  
  return () => {
    console.log(`Unsubscribed from user ${userId}`);
  };
}, [userId]);

// When userId changes: "Unsubscribed from user 1" → "Subscribed to user 2"

Common Use Cases

Fetching Data

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;

    async function fetchUser() {
      setLoading(true);
      const res = await fetch(`/api/users/${userId}`);
      const data = await res.json();
      
      if (!cancelled) {
        setUser(data);
        setLoading(false);
      }
    }

    fetchUser();
    return () => { cancelled = true; };
  }, [userId]);

  if (loading) return <p>Loading...</p>;
  return <h1>{user.name}</h1>;
}

The cancelled flag prevents setting state on an unmounted component.

Keyboard Events

useEffect(() => {
  function handleKeyDown(e) {
    if (e.key === "Escape") {
      onClose();
    }
  }
  
  document.addEventListener("keydown", handleKeyDown);
  return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);

Timer / Interval

useEffect(() => {
  const timer = setInterval(() => {
    setSeconds(s => s + 1);
  }, 1000);
  
  return () => clearInterval(timer);
}, []);

Updating Document Title

useEffect(() => {
  document.title = `${count} new messages`;
}, [count]);

Common Mistakes

1. Missing Dependencies

// Bug — `count` is used but not in dependency array
useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // Always logs initial value!
  }, 1000);
  return () => clearInterval(timer);
}, []); // ← missing `count`

// Fix — use functional update
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1); // No dependency needed!
  }, 1000);
  return () => clearInterval(timer);
}, []);

2. Object Dependencies Causing Infinite Loops

// Infinite loop! {} !== {} on every render
useEffect(() => {
  fetchData(options);
}, [options]); // options is recreated every render

// Fix — memoize or use primitive values
const optionsKey = `${options.page}-${options.sort}`;
useEffect(() => {
  fetchData(options);
}, [optionsKey]);

3. Async Directly in useEffect

// Wrong — useEffect can't be async
useEffect(async () => {
  const data = await fetchData(); // Don't do this!
}, []);

// Correct — define async function inside
useEffect(() => {
  async function load() {
    const data = await fetchData();
    setData(data);
  }
  load();
}, []);

useEffect Timing

Effects run after the browser paints. This means:

  • The UI updates first (users see the new state)
  • Then the effect runs

If you need to run code before paint (e.g., measuring DOM), use useLayoutEffect instead.

When NOT to Use useEffect

Not everything needs useEffect. Ask yourself: "Is this a side effect, or just a computation?"

Don't use useEffect for:

// Transforming data — just compute it inline
const filtered = items.filter(i => i.active);

// Handling events — use event handlers
<button onClick={() => setCount(c + 1)}>

// Initializing state — use useState with initializer
const [data] = useState(() => computeInitial());

Do use useEffect for:

  • API calls and data fetching
  • Subscriptions (WebSocket, event listeners)
  • Timers and intervals
  • Direct DOM manipulation
  • Syncing with external systems

Key Takeaways

  1. useEffect runs side effects — things outside React's render cycle
  2. Dependency array controls when: [] = mount only, [x] = when x changes
  3. Always clean up subscriptions, listeners, and timers
  4. Don't use useEffect for computations — just compute inline
  5. Use a cancelled/aborted flag to prevent state updates after unmount
  6. When in doubt about dependencies, trust the ESLint exhaustive-deps rule

🚀 Practice What You Learned

Apply these concepts with hands-on coding challenges: