Skip to content

Quick Answer

useEffect : Fix Common React Problems 2025 Master React's useEffect hook with practical solutions to infinite loops, cleanup issues, dependency arrays, and more.

Web Development

useEffect Guide: Fix Common React Problems 2025

12 min read
ReactJavaScriptWeb DevelopmentHooksFrontend

useEffect is React's most misunderstood hook. Every React developer has spent hours debugging infinite loops, stale closures, and mysterious re-renders.

After years of building React applications and helping others debug their useEffect issues, here's the complete guide I wish I had when I started.

Quick Answer#

Common useEffect problems and solutions:

  1. Infinite loops → Check dependency array, avoid object/array dependencies
  2. Stale closures → Include all dependencies, use functional updates
  3. Cleanup not running → Return cleanup function, handle unmounting
  4. Too many re-renders → Memoize dependencies, use refs for values that shouldn't trigger effects
  5. Race conditions → Use cleanup to cancel async operations

Rule of thumb: If a value is used inside useEffect, it should be in the dependency array (unless you have a specific reason to exclude it).


Understanding useEffect Basics#

What useEffect Does#

useEffect lets you perform side effects in functional components. Side effects are things like:

  • Fetching data
  • Setting up subscriptions
  • Manually changing the DOM
  • Starting timers
useEffect(() => {
  // Side effect code here
}, [dependencies]); // Dependency array

The Dependency Array#

The dependency array tells React when to run your effect:

  • No array → Runs after every render
  • Empty array [] → Runs once after mount
  • With dependencies [dep1, dep2] → Runs when dependencies change

Problem 1: Infinite Loops#

The Problem#

Your component re-renders infinitely, crashing the browser.

// ❌ Bad: Infinite loop
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data)); // This triggers re-render
  }); // Missing dependency array!

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

Why it loops: Without a dependency array, useEffect runs after every render. setUser triggers a re-render, which runs useEffect again, creating an infinite cycle.

Solution 1: Add Dependency Array#

// ✅ Good: Only runs when userId changes
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]); // Only re-run when userId changes

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

Solution 2: Fix Object/Array Dependencies#

// ❌ Bad: Object reference changes every render
function Component({ config }) {
  useEffect(() => {
    doSomething(config);
  }, [config]); // config is a new object every render!

  return <div>...</div>;
}

// ✅ Good: Depend on specific values
function Component({ config }) {
  useEffect(() => {
    doSomething(config);
  }, [config.apiUrl, config.timeout]); // Depend on primitives

  return <div>...</div>;
}

// ✅ Better: Memoize the config
function Component({ config }) {
  const memoizedConfig = useMemo(() => config, [config.apiUrl, config.timeout]);

  useEffect(() => {
    doSomething(memoizedConfig);
  }, [memoizedConfig]);

  return <div>...</div>;
}

Solution 3: Use Functional Updates#

// ❌ Bad: Depends on state that changes
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1); // Uses stale count
    }, 1000);
    return () => clearInterval(timer);
  }, [count]); // Re-creates timer every second!

  return <div>{count}</div>;
}

// ✅ Good: Functional update doesn't need count in deps
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(prev => prev + 1); // Uses latest count
    }, 1000);
    return () => clearInterval(timer);
  }, []); // Only runs once

  return <div>{count}</div>;
}

Problem 2: Stale Closures#

The Problem#

Your effect uses old values, even though state has updated.

// ❌ Bad: Stale closure
function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('message', (msg) => {
      setMessages([...messages, msg]); // Uses stale messages!
    });
    connection.connect();
  }, [roomId]); // Missing messages dependency

  return <div>{messages.map(m => <div key={m.id}>{m.text}</div>)}</div>;
}

Why it's stale: The effect closure captures the initial messages array. When new messages arrive, it still uses the old empty array.

Solution 1: Include All Dependencies#

// ✅ Good: Includes messages in dependencies
function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('message', (msg) => {
      setMessages(prev => [...prev, msg]); // Functional update
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, messages]); // Now includes messages

  return <div>{messages.map(m => <div key={m.id}>{m.text}</div>)}</div>;
}

Better: Use functional updates to avoid needing messages in deps:

// ✅ Better: Functional update, no messages dependency needed
function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('message', (msg) => {
      setMessages(prev => [...prev, msg]); // Always uses latest
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // Only roomId needed

  return <div>{messages.map(m => <div key={m.id}>{m.text}</div>)}</div>;
}

Solution 2: Use Refs for Values That Shouldn't Trigger Effects#

// ✅ Good: Use ref for value that shouldn't trigger re-runs
function SearchBox({ onSearch }) {
  const [query, setQuery] = useState('');
  const queryRef = useRef(query);

  // Keep ref in sync
  useEffect(() => {
    queryRef.current = query;
  }, [query]);

  // Effect doesn't re-run when query changes
  useEffect(() => {
    const timer = setTimeout(() => {
      onSearch(queryRef.current); // Uses latest query
    }, 500);
    return () => clearTimeout(timer);
  }, [onSearch]); // Only re-run when onSearch changes

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Problem 3: Cleanup Not Running#

The Problem#

Your cleanup function doesn't run, causing memory leaks or stale subscriptions.

// ❌ Bad: No cleanup
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
    // Missing cleanup!
  }, []);

  return <div>{seconds} seconds</div>;
}

Why it's a problem: When the component unmounts, the interval keeps running, causing memory leaks and potential errors.

Solution: Always Return Cleanup#

// ✅ Good: Cleanup function
function Timer() {
  const [seconds, setSeconds] = useState(0);

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

  return <div>{seconds} seconds</div>;
}

Real-World Example: API Subscriptions#

// ✅ Good: Cleanup for subscriptions
function UserStatus({ userId }) {
  const [status, setStatus] = useState('offline');

  useEffect(() => {
    const subscription = subscribeToUserStatus(userId, (newStatus) => {
      setStatus(newStatus);
    });

    return () => {
      subscription.unsubscribe(); // Cleanup subscription
    };
  }, [userId]);

  return <div>Status: {status}</div>;
}

Problem 4: Race Conditions in Async Operations#

The Problem#

Multiple API calls race each other, and an older response overwrites a newer one.

// ❌ Bad: Race condition
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

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

The race: If userId changes quickly (user navigates fast), the first request might finish after the second, showing wrong data.

Solution: Cancel Previous Requests#

// ✅ Good: Cancel previous requests
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false; // Flag to track if effect is still valid

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) { // Only update if not cancelled
          setUser(data);
        }
      });

    return () => {
      cancelled = true; // Mark as cancelled on cleanup
    };
  }, [userId]);

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

Better Solution: Use AbortController#

// ✅ Better: AbortController for fetch
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

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

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name !== 'AbortError') {
          console.error(err);
        }
      });

    return () => {
      controller.abort(); // Cancel the request
    };
  }, [userId]);

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

Problem 5: Effects Running Too Often#

The Problem#

Your effect runs on every render, even when it shouldn't.

// ❌ Bad: Runs too often
function ExpensiveComponent({ data }) {
  useEffect(() => {
    expensiveOperation(data); // Runs every render!
  }); // Missing dependency array

  return <div>...</div>;
}

Solution 1: Memoize Dependencies#

// ✅ Good: Memoize object dependencies
function ExpensiveComponent({ config }) {
  const memoizedConfig = useMemo(() => config, [
    config.apiUrl,
    config.timeout,
    config.retries
  ]);

  useEffect(() => {
    expensiveOperation(memoizedConfig);
  }, [memoizedConfig]);

  return <div>...</div>;
}

Solution 2: Use Refs for Callbacks#

// ✅ Good: Use ref for callback that shouldn't trigger effect
function Component({ onSave }) {
  const onSaveRef = useRef(onSave);

  useEffect(() => {
    onSaveRef.current = onSave; // Keep ref updated
  }, [onSave]);

  useEffect(() => {
    // This effect doesn't re-run when onSave changes
    const handleKeyPress = (e) => {
      if (e.key === 's' && e.ctrlKey) {
        onSaveRef.current(); // Uses latest onSave
      }
    };
    window.addEventListener('keydown', handleKeyPress);
    return () => window.removeEventListener('keydown', handleKeyPress);
  }, []); // Only runs once

  return <div>...</div>;
}

Common Patterns#

Pattern 1: Fetching Data on Mount#

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    async function fetchUsers() {
      try {
        setLoading(true);
        const response = await fetch('/api/users');
        const data = await response.json();
        
        if (!cancelled) {
          setUsers(data);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchUsers();

    return () => {
      cancelled = true;
    };
  }, []); // Only fetch on mount

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{users.map(u => <div key={u.id}>{u.name}</div>)}</div>;
}

Pattern 2: Subscribing to Events#

function WindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []); // Only set up listener once

  return <div>Window: {size.width} x {size.height}</div>;
}

Pattern 3: Updating Document Title#

function Page({ title }) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = title;
    
    return () => {
      document.title = previousTitle; // Restore previous title
    };
  }, [title]);

  return <div>...</div>;
}
function SearchBox({ onSearch }) {
  const [query, setQuery] = useState('');
  const queryRef = useRef(query);

  useEffect(() => {
    queryRef.current = query;
  }, [query]);

  useEffect(() => {
    const timer = setTimeout(() => {
      onSearch(queryRef.current);
    }, 500);

    return () => clearTimeout(timer);
  }, [query, onSearch]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Debugging Tips#

1. Use ESLint Plugin#

Install eslint-plugin-react-hooks to catch dependency issues:

{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/exhaustive-deps": "warn"
  }
}

2. Log Dependencies#

useEffect(() => {
  console.log('Effect running with:', { userId, filter });
  // Your effect code
}, [userId, filter]);

3. Use React DevTools#

React DevTools Profiler shows which effects run and why components re-render.

4. Check for Object Reference Changes#

const prevConfigRef = useRef();

useEffect(() => {
  if (prevConfigRef.current !== config) {
    console.log('Config changed:', config);
    prevConfigRef.current = config;
  }
}, [config]);

Best Practices Summary#

  1. Always include dependencies - If you use a value, include it in the dependency array
  2. Use functional updates - setState(prev => ...) avoids stale closures
  3. Always cleanup - Return cleanup functions for subscriptions, timers, listeners
  4. Handle race conditions - Cancel async operations in cleanup
  5. Memoize object dependencies - Use useMemo for objects/arrays in dependencies
  6. Use refs for callbacks - When callbacks shouldn't trigger effect re-runs
  7. Enable ESLint plugin - Catch dependency issues automatically

When NOT to Use useEffect#

Sometimes, useEffect isn't the right tool:

1. Transforming Data for Render#

// ❌ Bad: Using useEffect for derived state
function UserList({ users }) {
  const [filteredUsers, setFilteredUsers] = useState([]);

  useEffect(() => {
    setFilteredUsers(users.filter(u => u.active));
  }, [users]);

  return <div>...</div>;
}

// ✅ Good: Compute during render
function UserList({ users }) {
  const filteredUsers = users.filter(u => u.active);
  return <div>...</div>;
}

2. Handling Events#

// ❌ Bad: Using useEffect for event handlers
function Button() {
  useEffect(() => {
    const handleClick = () => console.log('clicked');
    button.addEventListener('click', handleClick);
    return () => button.removeEventListener('click', handleClick);
  }, []);

  return <button>Click me</button>;
}

// ✅ Good: Use event handler prop
function Button({ onClick }) {
  return <button onClick={onClick}>Click me</button>;
}

3. Resetting State on Prop Change#

// ❌ Bad: useEffect to reset state
function Form({ userId }) {
  const [name, setName] = useState('');

  useEffect(() => {
    setName(''); // Reset when userId changes
  }, [userId]);

  return <input value={name} onChange={e => setName(e.target.value)} />;
}

// ✅ Good: Use key prop
function Form({ userId }) {
  const [name, setName] = useState('');
  return <input key={userId} value={name} onChange={e => setName(e.target.value)} />;
}

Key Takeaways#

  1. Dependency array is critical - Missing deps cause bugs, wrong deps cause infinite loops
  2. Always cleanup - Prevent memory leaks with cleanup functions
  3. Handle async properly - Cancel requests to avoid race conditions
  4. Use functional updates - Avoid stale closures with setState(prev => ...)
  5. Memoize object deps - Prevent unnecessary re-runs
  6. Enable ESLint plugin - Catch issues automatically
  7. Don't overuse useEffect - Some things are better computed during render

useEffect is powerful but requires understanding React's rendering cycle. Master these patterns, and you'll debug useEffect issues in minutes instead of hours.


FAQ#

Q: Should I always include all dependencies? A: Yes, unless you have a specific reason not to (like using a ref for a value that shouldn't trigger re-runs).

Q: Why does my effect run twice in development? A: React Strict Mode intentionally double-invokes effects in development to help find bugs. This won't happen in production.

Q: Can I use async functions directly in useEffect? A: No, but you can define an async function inside useEffect and call it.

Q: How do I prevent an effect from running on mount? A: Use a ref to track if it's the first render, or restructure your logic.

Q: What's the difference between useEffect and useLayoutEffect? A: useLayoutEffect runs synchronously after DOM mutations but before paint. Use it when you need to measure DOM or prevent visual flicker.

Share this article

Related Articles

Related Posts

Web DevelopmentNew
·
10 min read
⭐ Featured

TypeScript Type vs Interface: When to Use Each (2025)

The definitive guide to choosing between TypeScript's type and interface. Learn when to use each with real-world examples, common patterns, and practical decision guidelines.

TypeScriptJavaScriptWeb Development+2 more
Developer ToolsNew
·
5 min read

How I Cut My Debugging Time in Half as a React Developer

After tracking my debugging sessions for a month, I discovered that 80% of my bugs fell into 5 categories. Here's what I changed and the tools that actually made a difference.

ReactDebuggingDeveloper Tools+2 more
Projects
·
2 min read

How I Built This Portfolio with Next.js 16 and Tailwind CSS

A deep dive into the architecture, design decisions, and optimizations that went into building my personal portfolio site. From SEO to accessibility, learn what goes into a modern developer portfolio.

Next.jsReactTailwind CSS+2 more