React.js Fundamentals

useEffect Hook - Managing Side Effects

20 min Lesson 11 of 40

Understanding Side Effects in React

Side effects are operations that affect something outside the scope of the component function being executed. Common examples include data fetching, subscriptions, timers, manually changing the DOM, and logging.

The useEffect hook lets you perform side effects in function components. It serves the same purpose as componentDidMount, componentDidUpdate, and componentWillUnmount in class components, but unified into a single API.

Why Side Effects Need Special Handling

React components should be pure functions - given the same props and state, they should render the same UI. Side effects break this purity, so React provides useEffect to handle them predictably.

Basic useEffect Syntax

The useEffect hook takes two arguments: a function containing your side effect code, and an optional dependency array.

import React, { useState, useEffect } from 'react';

function DocumentTitleUpdater() {
  const [count, setCount] = useState(0);

  // Effect runs after every render
  useEffect(() => {
    document.title = `Count: ${count}`;
    console.log('Effect executed');
  });

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

export default DocumentTitleUpdater;

In this example, the effect runs after every render, updating the document title to reflect the current count.

The Dependency Array

The second argument to useEffect is a dependency array that controls when the effect runs:

  • No dependency array: Effect runs after every render
  • Empty array []: Effect runs only once after initial render
  • Array with values: Effect runs only when those values change
import React, { useState, useEffect } from 'react';

function DependencyDemo() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('John');

  // Runs only on mount (once)
  useEffect(() => {
    console.log('Component mounted');
  }, []);

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

  // Runs when name changes
  useEffect(() => {
    console.log(`Name changed to: ${name}`);
  }, [name]);

  // Runs when either count or name changes
  useEffect(() => {
    console.log(`Count: ${count}, Name: ${name}`);
  }, [count, name]);

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>

      <h2>Name: {name}</h2>
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
    </div>
  );
}

export default DependencyDemo;

Common Mistake: Missing Dependencies

Always include all values from the component scope that are used inside the effect in the dependency array. Omitting dependencies can lead to stale data and bugs. Use ESLint plugin eslint-plugin-react-hooks to catch these issues.

Cleanup Functions

Effects can optionally return a cleanup function that React will run before the component unmounts or before running the effect again. This is essential for preventing memory leaks.

import React, { useState, useEffect } from 'react';

function TimerComponent() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(true);

  useEffect(() => {
    if (!isRunning) return;

    // Setup: Create interval
    const intervalId = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);

    // Cleanup: Clear interval when component unmounts
    // or when dependencies change
    return () => {
      console.log('Cleaning up interval');
      clearInterval(intervalId);
    };
  }, [isRunning]); // Re-run effect when isRunning changes

  return (
    <div>
      <h2>Seconds Elapsed: {seconds}</h2>
      <button onClick={() => setIsRunning(!isRunning)}>
        {isRunning ? 'Pause' : 'Resume'}
      </button>
    </div>
  );
}

export default TimerComponent;

When to Use Cleanup

Use cleanup functions for: subscriptions, timers (setTimeout/setInterval), event listeners, WebSocket connections, and any other resources that need manual cleanup.

Fetching Data with useEffect

One of the most common use cases for useEffect is fetching data from an API when the component mounts or when certain values change.

import React, { useState, useEffect } from 'react';

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

  useEffect(() => {
    // Reset state when userId changes
    setLoading(true);
    setError(null);

    // Fetch user data
    fetch(`https://api.example.com/users/${userId}`)
      .then(response => {
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        return response.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]); // Re-fetch when userId changes

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
      <p>Username: {user.username}</p>
    </div>
  );
}

export default UserProfile;

Async Functions in useEffect

You cannot make the effect callback itself async, but you can define and call an async function inside it:

import React, { useState, useEffect } from 'react';

function PostsList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Define async function inside effect
    const fetchPosts = async () => {
      try {
        setLoading(true);
        const response = await fetch('https://jsonplaceholder.typicode.com/posts');
        const data = await response.json();
        setPosts(data.slice(0, 5)); // Get first 5 posts
      } catch (error) {
        console.error('Error fetching posts:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();
  }, []); // Empty array - fetch once on mount

  if (loading) {
    return <div>Loading posts...</div>;
  }

  return (
    <div>
      <h2>Recent Posts</h2>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.body.substring(0, 100)}...</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default PostsList;

Common useEffect Patterns

1. Event Listeners

function WindowSize() {
  const [windowWidth, setWindowWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => {
      setWindowWidth(window.innerWidth);
    };

    window.addEventListener('resize', handleResize);

    // Cleanup: Remove event listener
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // No dependencies - setup once

  return <div>Window width: {windowWidth}px</div>;
}

2. Debouncing Search Input

function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    // Debounce: Wait 500ms after user stops typing
    const timerId = setTimeout(() => {
      if (searchTerm) {
        fetch(`https://api.example.com/search?q=${searchTerm}`)
          .then(res => res.json())
          .then(data => setResults(data));
      } else {
        setResults([]);
      }
    }, 500);

    // Cleanup: Cancel previous timer
    return () => clearTimeout(timerId);
  }, [searchTerm]);

  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search..."
      />
      <ul>
        {results.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

3. Local Storage Sync

function ThemeSelector() {
  const [theme, setTheme] = useState(() => {
    // Initialize from localStorage
    return localStorage.getItem('theme') || 'light';
  });

  useEffect(() => {
    // Sync to localStorage whenever theme changes
    localStorage.setItem('theme', theme);
    document.body.className = theme;
  }, [theme]);

  return (
    <div>
      <button onClick={() => setTheme('light')}>Light</button>
      <button onClick={() => setTheme('dark')}>Dark</button>
      <p>Current theme: {theme}</p>
    </div>
  );
}

Exercise 1: Live Clock

Create a LiveClock component that displays the current time and updates every second. The clock should stop updating when the component unmounts.

// Requirements:
// 1. Display time in HH:MM:SS format
// 2. Update every second using setInterval
// 3. Clean up interval on unmount
// 4. Add a "Stop" button to pause updates

Exercise 2: User Search

Build a UserSearch component that fetches users from https://jsonplaceholder.typicode.com/users and filters them based on search input.

// Requirements:
// 1. Fetch users on component mount
// 2. Filter users by name as user types
// 3. Debounce the filter by 300ms
// 4. Show loading state while fetching

Exercise 3: Mouse Tracker

Create a MouseTracker component that displays the current mouse position (x, y coordinates) and updates as the mouse moves.

// Requirements:
// 1. Track mousemove events on window
// 2. Display X and Y coordinates
// 3. Clean up event listener on unmount
// 4. Throttle updates to every 100ms for performance

Summary

  • useEffect handles side effects in function components
  • Dependency array controls when effects run
  • Return cleanup functions to prevent memory leaks
  • Common patterns: data fetching, subscriptions, timers, event listeners
  • Use async functions inside effects, not as the effect callback itself
  • Always include all dependencies to avoid stale closures