Back to all articles
ReactJavaScriptFrontendHooks

Understanding React useEffect: The Hook That Confuses Every Developer

22 December 20247 min readDarshan Singh

Understanding React useEffect: The Hook That Confuses Every Developer

If you've worked with React for more than a week, you've encountered useEffect. You've probably also had it behave unexpectedly — running too many times, not running when you expected, or creating infinite loops.

This guide explains useEffect clearly, with examples of both correct and incorrect usage.

What useEffect Is For

useEffect lets you synchronize your component with external systems — APIs, the DOM, timers, subscriptions. It runs after the component renders and optionally cleans up after itself.

The conceptual model: "After this render, do this thing."

The Dependency Array

// No dependency array — runs after EVERY render
useEffect(() => { ... });

// Empty array — runs once, after the first render only
useEffect(() => { ... }, []);

// With dependencies — runs when dependencies change
useEffect(() => { ... }, [userId, filter]);

Common Mistake 1: Missing Dependencies

// ❌ Wrong — userId is used but not in dependency array
useEffect(() => {
  fetchUser(userId);
}, []);

// ✅ Correct
useEffect(() => {
  fetchUser(userId);
}, [userId]);

Common Mistake 2: Infinite Loops

// ❌ This creates an infinite loop
const [data, setData] = useState([]);

useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(result => setData(result));
}); // No dependency array — runs after EVERY render

// ✅ Correct — fetches once on mount
useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(result => setData(result));
}, []);

Common Mistake 3: Object and Function Dependencies

Objects and functions are recreated on every render. Including them as dependencies causes the effect to run every render.

// ✅ Option 1 — define inside effect
useEffect(() => {
  const options = { method: 'GET' };
  fetchData(options);
}, []);

// ✅ Option 2 — memoize
const options = useMemo(() => ({ method: 'GET' }), []);
useEffect(() => {
  fetchData(options);
}, [options]);

The Cleanup Function

useEffect(() => {
  const subscription = someAPI.subscribe(userId, handleUpdate);
  
  return () => {
    subscription.unsubscribe();
  };
}, [userId]);

Without cleanup, subscriptions and timers continue running after the component unmounts — a common source of memory leaks.

Fetching Data the Right Way

useEffect(() => {
  let cancelled = false;
  
  async function fetchUser() {
    try {
      const res = await fetch(`/api/users/${userId}`);
      const data = await res.json();
      if (!cancelled) setUser(data);
    } catch (err) {
      if (!cancelled) setError(err.message);
    }
  }
  
  fetchUser();
  return () => { cancelled = true; };
}, [userId]);

When NOT to Use useEffect

React's documentation specifically calls out patterns where useEffect is the wrong tool:

  • Transforming data for rendering: Do this during render, not in effects
  • User event handling: Use event handlers, not effects
  • Initializing the app: Run code outside components

Building a React project and running into issues? Get in touch — I'd be happy to help.

Related

Continue reading

Articles connected by topic or service area.