Back to All Blogs

Understanding React Hooks: useEffect Explained

Master the useEffect hook with practical examples, common pitfalls to avoid, and best practices for managing side effects in React.

7 min readBy Qtechs
ReactHooksuseEffectJavaScriptWeb Development

Introduction

The useEffect hook is one of React's most powerful yet misunderstood features. It's your go-to tool for handling side effects in functional components – from data fetching to subscribing to events. In this comprehensive guide, we'll demystify useEffect and show you how to use it effectively.

What is useEffect?

useEffect is a React Hook that lets you perform side effects in functional components. Side effects are operations that affect something outside the component, such as:

  • Fetching data from an API
  • Setting up subscriptions
  • Manually changing the DOM
  • Setting up timers
  • Logging to the console

Before Hooks, you'd use lifecycle methods like componentDidMount, componentDidUpdate, and componentWillUnmount in class components. useEffect combines all three into one API.

Basic Syntax

The basic structure of useEffect looks like this:

useEffect(() => {
  // Side effect code here
  
  return () => {
    // Cleanup code here (optional)
  };
}, [dependencies]);

The hook takes two arguments:

  1. A function that contains your side effect code
  2. An optional dependency array that controls when the effect runs

Running Effects on Every Render

If you omit the dependency array, the effect runs after every render:

import { useState, useEffect } from 'react';
 
function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    console.log('Component rendered or updated');
  });
  
  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

Warning: This pattern is rarely what you want. Running effects on every render can cause performance issues.

Running Effects Once (On Mount)

Pass an empty dependency array to run the effect only once when the component mounts:

function DataFetcher() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    fetch('https://api.example.com/data')
      .then(response => response.json())
      .then(data => setData(data));
  }, []); // Empty array = run once
  
  return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
}

This is equivalent to componentDidMount in class components.

Running Effects on Specific Changes

List dependencies in the array to run the effect only when those values change:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetch(`https://api.example.com/users/${userId}`)
      .then(response => response.json())
      .then(data => setUser(data));
  }, [userId]); // Re-run when userId changes
  
  return <div>{user?.name}</div>;
}

React compares dependency values between renders using Object.is(). If any dependency changed, the effect runs again.

Cleanup Functions

Some effects need cleanup to prevent memory leaks. Return a cleanup function from your effect:

function Timer() {
  const [seconds, setSeconds] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
    
    // Cleanup: clear the interval when component unmounts
    return () => {
      clearInterval(interval);
    };
  }, []);
  
  return <div>Seconds: {seconds}</div>;
}

React runs the cleanup function:

  • Before re-running the effect (if dependencies changed)
  • When the component unmounts

Common Patterns and Examples

Data Fetching with Loading States

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    async function fetchProducts() {
      try {
        setLoading(true);
        const response = await fetch('/api/products');
        const data = await response.json();
        setProducts(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    
    fetchProducts();
  }, []);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Event Listeners

function WindowSize() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    function handleResize() {
      setWindowSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    }
    
    window.addEventListener('resize', handleResize);
    
    // Cleanup: remove listener on unmount
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return (
    <div>
      Window: {windowSize.width} x {windowSize.height}
    </div>
  );
}

Subscribing to External Data

function ChatRoom({ roomId }: { roomId: string }) {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    const subscription = chatAPI.subscribe(roomId, (message) => {
      setMessages(prev => [...prev, message]);
    });
    
    // Cleanup: unsubscribe when roomId changes or component unmounts
    return () => {
      subscription.unsubscribe();
    };
  }, [roomId]);
  
  return (
    <div>
      {messages.map((msg, i) => (
        <div key={i}>{msg}</div>
      ))}
    </div>
  );
}

Document Title Updates

function useDocumentTitle(title: string) {
  useEffect(() => {
    const prevTitle = document.title;
    document.title = title;
    
    // Restore previous title on unmount
    return () => {
      document.title = prevTitle;
    };
  }, [title]);
}
 
function ProductPage({ product }) {
  useDocumentTitle(`${product.name} - My Store`);
  
  return <div>{product.name}</div>;
}

Common Pitfalls and How to Avoid Them

Pitfall 1: Missing Dependencies

Problem:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    fetchResults(query).then(setResults);
  }, []); // Missing 'query' dependency!
  
  return <div>{results.length} results</div>;
}

Solution: Include all values from the component scope that the effect uses:

useEffect(() => {
  fetchResults(query).then(setResults);
}, [query]); // Now it re-fetches when query changes

Pitfall 2: Infinite Loops

Problem:

function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(count + 1); // Creates infinite loop!
  }, [count]);
}

Solution: Use functional updates or reconsider your effect logic:

useEffect(() => {
  setCount(c => c + 1); // Doesn't depend on count
}, []); // Runs once

Pitfall 3: Stale Closures

Problem:

function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      console.log(count); // Always logs 0!
    }, 1000);
    
    return () => clearInterval(interval);
  }, []);
}

Solution: Use functional updates or include the dependency:

useEffect(() => {
  const interval = setInterval(() => {
    setCount(c => c + 1); // Uses current value
  }, 1000);
  
  return () => clearInterval(interval);
}, []);

Pitfall 4: Fetching in useEffect Without Cleanup

Problem:

useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(setData); // Component might have unmounted!
}, []);

Solution: Use an abort controller or a flag:

useEffect(() => {
  let cancelled = false;
  
  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      if (!cancelled) {
        setData(data);
      }
    });
  
  return () => {
    cancelled = true;
  };
}, []);

Advanced Patterns

Debouncing Effects

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    const timer = setTimeout(() => {
      fetch(`/api/search?q=${query}`)
        .then(res => res.json())
        .then(setResults);
    }, 500); // Wait 500ms after user stops typing
    
    return () => clearTimeout(timer);
  }, [query]);
  
  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  );
}

Synchronizing with External Systems

function MapComponent({ center }: { center: [number, number] }) {
  const mapRef = useRef(null);
  
  useEffect(() => {
    const map = new MapLibrary(mapRef.current);
    map.setCenter(center);
    
    return () => {
      map.destroy();
    };
  }, [center]);
  
  return <div ref={mapRef} />;
}

When NOT to Use useEffect

Not everything needs useEffect. Avoid it for:

Transforming data for rendering:

// Bad
function TodoList({ todos }) {
  const [filtered, setFiltered] = useState([]);
  
  useEffect(() => {
    setFiltered(todos.filter(t => !t.completed));
  }, [todos]);
}
 
// Good
function TodoList({ todos }) {
  const filtered = todos.filter(t => !t.completed);
}

Event handlers:

// Bad
function Form() {
  const [value, setValue] = useState('');
  
  useEffect(() => {
    // Don't do this!
  }, [value]);
}
 
// Good
function Form() {
  const [value, setValue] = useState('');
  
  function handleChange(e) {
    setValue(e.target.value);
    // Handle the change here
  }
}

Best Practices

  1. Always specify dependencies honestly – include everything your effect uses
  2. Use multiple effects to separate concerns instead of one large effect
  3. Clean up side effects to prevent memory leaks
  4. Keep effects focused – each effect should do one thing
  5. Consider custom hooks to reuse effect logic
  6. Use ESLint plugineslint-plugin-react-hooks catches mistakes

Conclusion

useEffect is a powerful tool for managing side effects in React applications. The key to using it well is understanding:

  • When effects run (mount, update, unmount)
  • How the dependency array controls execution
  • Why cleanup functions matter
  • Common pitfalls and how to avoid them

Start simple, use the React DevTools to debug, and rely on the ESLint plugin to catch issues early. With practice, useEffect will become second nature, and you'll be able to handle any side effect your application needs.

Remember: effects are about synchronization, not lifecycle. Think about what you're synchronizing with (props, state, external systems) rather than when code should run, and you'll write better, more maintainable React code.

Q

Qtechs

Author

Related Posts

Why Next.js Is Perfect for Modern Web Apps

8th Feb 255 min read

Discover how Next.js combines performance, SEO, and developer experience to create lightning-fast web applications that scale.

Read More →

10 TypeScript Tips for Writing Better Code

5th Feb 255 min read

Master TypeScript with these practical tips that will help you write more maintainable, type-safe code and avoid common pitfalls.

Read More →

Getting Started with React Server Components

1st Feb 256 min read

Learn how React Server Components are revolutionizing web development by reducing bundle sizes and improving performance.

Read More →