Intermediate30 min read

Side Effects and useEffect Hook

Master the useEffect hook to handle side effects like data fetching, subscriptions, and DOM manipulation.

Topics Covered:

Side EffectsuseEffectDependency ArraysCleanup

Prerequisites:

  • Managing State with useState

Video Tutorial

Overview

Side effects are operations that affect something outside the component's render, like API calls, setting up subscriptions, or manually changing the DOM. React components are 'pure functions' - given the same props and state, they should always return the same JSX. But applications need to do things like fetch data, set up subscriptions, or update the DOM - these are 'side effects'. The useEffect hook is React's way of handling side effects in function components. Understanding useEffect is crucial for building real-world applications that interact with APIs, manage subscriptions, and handle cleanup.

Understanding Side Effects in React

In React, side effects are operations that interact with the outside world - anything that doesn't directly relate to rendering JSX. React components should be 'pure functions' that return JSX based on props and state, but real applications need to do more. Common Side Effects: • Fetching data from an API • Setting up subscriptions (websockets, event listeners) • Manually changing the DOM (updating document title, focusing inputs) • Starting/stopping timers • Logging analytics • Reading from/writing to localStorage Why useEffect? • Separates side effects from rendering logic • Runs after render, not during (won't block rendering) • Provides cleanup mechanism • Can control when effects run using dependencies

useEffect Basic Syntax

useEffect accepts two arguments: a function (the effect) and an optional dependency array. The effect function runs after the component renders to the screen.

Code Example:
import { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  // Effect runs after every render
  useEffect(() => {
    document.title = `Count: ${count}`;
  });
  
  // Effect runs only once (after mount)
  useEffect(() => {
    console.log('Component mounted');
  }, []); // Empty dependency array
  
  // Effect runs when count changes
  useEffect(() => {
    console.log('Count changed to:', count);
  }, [count]); // List dependencies
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
    </div>
  );
}

The dependency array controls when the effect runs. No array = every render. Empty array [] = once after mount. Array with values = when those values change.

The Three Types of useEffect

There are three main patterns for useEffect, each serving different purposes: 1. Effect with no dependencies - runs after every render 2. Effect with empty dependencies - runs once after mount 3. Effect with dependencies - runs when dependencies change

Code Example:
// Pattern 1: Run after every render (rarely needed)
useEffect(() => {
  console.log('Rendered');
  // This runs after EVERY render
  // Usually not what you want - can cause performance issues
});

// Pattern 2: Run once on mount (common for setup)
useEffect(() => {
  // Fetch initial data
  fetchUserData();
  
  // Set up subscriptions
  const subscription = subscribeToUpdates();
  
  // Update document title once
  document.title = 'My App';
  
  // Cleanup on unmount
  return () => {
    subscription.unsubscribe();
  };
}, []); // Empty array = run once

// Pattern 3: Run when dependencies change (most common)
useEffect(() => {
  // Fetch data when userId changes
  fetchUserPosts(userId);
  
  // Update title when count changes
  document.title = `Count: ${count}`;
}, [userId, count]); // List all dependencies

Most effects fall into pattern 2 or 3. Pattern 1 (no dependencies) is rarely needed and can cause infinite loops or performance issues if not careful.

Dependency Array Rules

The dependency array is crucial for useEffect. Understanding when to include dependencies and the rules around them prevents bugs. Key Rules: • Include ALL values from component scope used in the effect • Missing dependencies can cause stale closures • Extra dependencies cause unnecessary re-runs • Functions and objects should be wrapped in useCallback/useMemo if used as dependencies • ESLint's exhaustive-deps rule helps catch missing dependencies

Code Example:
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [showDetails, setShowDetails] = useState(false);
  
  // ❌ MISSING DEPENDENCY - userId not in array
  useEffect(() => {
    fetchUser(userId);  // Uses userId but not in deps
  }, []); // Missing userId!
  
  // ✅ CORRECT - includes userId
  useEffect(() => {
    fetchUser(userId);
  }, [userId]); // userId is a dependency
  
  // ❌ MISSING DEPENDENCY - function in effect
  const formatUser = (user) => {
    return user.name.toUpperCase();
  };
  
  useEffect(() => {
    const formatted = formatUser(user);  // Uses formatUser
  }, [user]); // Missing formatUser!
  
  // ✅ CORRECT - move function inside effect
  useEffect(() => {
    const formatUser = (user) => user.name.toUpperCase();
    const formatted = formatUser(user);
  }, [user]);
  
  // ✅ OR wrap in useCallback
  const formatUser = useCallback((user) => {
    return user.name.toUpperCase();
  }, []);
  
  useEffect(() => {
    const formatted = formatUser(user);
  }, [user, formatUser]);
  
  // Dependency on props/state
  useEffect(() => {
    if (showDetails) {
      loadUserDetails(userId);
    }
  }, [userId, showDetails]); // Both are used

Always include all values from component scope that the effect uses. Missing dependencies cause stale closures where the effect uses old values. The ESLint rule 'react-hooks/exhaustive-deps' helps catch these issues.

Cleanup Functions - Preventing Memory Leaks

Cleanup functions are essential for preventing memory leaks. They run before the component unmounts or before the effect runs again (if dependencies changed). Always clean up: • Timers (setInterval, setTimeout) • Subscriptions (websockets, event listeners) • Cancelled API requests • Event listeners added to DOM

Code Example:
// Timer cleanup
useEffect(() => {
  const timer = setInterval(() => {
    console.log('Tick');
  }, 1000);
  
  // Cleanup function
  return () => {
    clearInterval(timer);
  };
}, []);

// Event listener cleanup
useEffect(() => {
  const handleResize = () => {
    console.log('Window resized');
  };
  
  window.addEventListener('resize', handleResize);
  
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

// Subscription cleanup
useEffect(() => {
  const subscription = dataStream.subscribe(data => {
    setData(data);
  });
  
  return () => {
    subscription.unsubscribe();
  };
}, []);

// Cleanup with dependencies - runs before re-running effect
useEffect(() => {
  const controller = new AbortController();
  
  fetch(`/api/users/${userId}`, {
    signal: controller.signal
  })
    .then(res => res.json())
    .then(setUser);
  
  // Cleanup cancels request if userId changes or component unmounts
  return () => {
    controller.abort();
  };
}, [userId]);

Cleanup functions prevent memory leaks. They run before unmount AND before the effect runs again (if dependencies changed). Always clean up timers, subscriptions, and event listeners.

Data Fetching with useEffect

Fetching data is one of the most common use cases for useEffect. However, there are important patterns to follow to handle loading states, errors, and cleanup properly.

Code Example:
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    // Reset states when userId changes
    setLoading(true);
    setError(null);
    setUser(null);
    
    // AbortController for cleanup
    const controller = new AbortController();
    
    async function fetchUser() {
      try {
        const response = await fetch(
          `/api/users/${userId}`,
          { signal: controller.signal }
        );
        
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        
        const userData = await response.json();
        setUser(userData);
      } catch (err) {
        // Don't set error if request was aborted
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
    
    // Cleanup: abort request if userId changes or component unmounts
    return () => {
      controller.abort();
    };
  }, [userId]);
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return null;
  
  return <div>{user.name}</div>;
}

Always handle loading and error states. Use AbortController to cancel requests when dependencies change or component unmounts. Reset states when fetching new data.

Common useEffect Mistakes

There are several common mistakes developers make with useEffect that can cause bugs, infinite loops, or performance issues.

Code Example:
// ❌ MISTAKE 1: Infinite loop - missing dependency
function BadCounter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(count + 1);  // Infinite loop!
  }); // Missing dependency array
  
  return <div>{count}</div>;
}

// ✅ CORRECT - Use dependency array or functional update
useEffect(() => {
  // Only run once on mount
  setCount(prev => prev + 1);
}, []);

// ❌ MISTAKE 2: Stale closure - object/array dependency
function BadEffect() {
  const [items, setItems] = useState([]);
  const filter = { category: 'electronics' };
  
  useEffect(() => {
    fetchItems(filter);  // filter is a new object each render
  }, [filter]); // filter changes every render!
}

// ✅ CORRECT - Use primitive values or memoize
useEffect(() => {
  const filter = { category: 'electronics' };
  fetchItems(filter);
}, []); // Or extract to state/useMemo

// ❌ MISTAKE 3: Not cleaning up
function BadTimer() {
  useEffect(() => {
    setInterval(() => {
      console.log('Tick');
    }, 1000);
    // Missing cleanup - memory leak!
  }, []);
}

// ✅ CORRECT - Always clean up
useEffect(() => {
  const timer = setInterval(() => {
    console.log('Tick');
  }, 1000);
  
  return () => clearInterval(timer);
}, []);

// ❌ MISTAKE 4: Effect that should be event handler
function BadForm() {
  const [email, setEmail] = useState('');
  
  useEffect(() => {
    validateEmail(email);  // Should be in onChange
  }, [email]);
}

// ✅ CORRECT - Use event handler
function GoodForm() {
  const [email, setEmail] = useState('');
  
  const handleChange = (e) => {
    setEmail(e.target.value);
    validateEmail(e.target.value);  // Directly in handler
  };
}

Common mistakes include infinite loops from missing dependencies, stale closures from object dependencies, not cleaning up resources, and using effects when event handlers are more appropriate.

When NOT to Use useEffect

Not everything needs useEffect! Many things can be handled differently, which is often cleaner and more performant. Don't use useEffect for: • Transforming data for rendering (do it during render) • Handling user events (use event handlers) • Resetting state on prop change (use key prop) • Computing derived state (calculate during render) • Initializing state (use useState initializer)

Code Example:
// ❌ BAD - Transforming data in effect
function UserList({ users }) {
  const [filteredUsers, setFilteredUsers] = useState([]);
  
  useEffect(() => {
    setFilteredUsers(users.filter(u => u.active));
  }, [users]); // Unnecessary effect
  
  return <div>{/* render */}</div>;
}

// ✅ GOOD - Transform during render
function UserList({ users }) {
  const filteredUsers = users.filter(u => u.active);
  return <div>{/* render */}</div>;
}

// ❌ BAD - Event handling in effect
function SearchBox() {
  const [query, setQuery] = useState('');
  
  useEffect(() => {
    if (query) {
      search(query);
    }
  }, [query]); // Should be in onChange
  
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

// ✅ GOOD - Handle in event handler
function SearchBox() {
  const [query, setQuery] = useState('');
  
  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value);
    if (value) {
      search(value);  // Directly in handler
    }
  };
  
  return <input value={query} onChange={handleChange} />;
}

// ❌ BAD - Resetting state when prop changes
function UserProfile({ userId }) {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    setData(null);  // Reset when userId changes
  }, [userId]);
}

// ✅ GOOD - Use key prop to reset component
<UserProfile key={userId} userId={userId} />

Many things that seem to need useEffect can be handled more simply. Transform data during render, handle events in event handlers, and use keys to reset components. Only use useEffect for true side effects.

Conclusion

useEffect is powerful but requires careful dependency management. Always include all dependencies to avoid bugs.