Introduction to useEffect

What is useEffect?

useEffect is one of React's built-in hooks that allows you to perform side effects in function components. Think of side effects as operations that affect something outside the scope of the current function - like data fetching, subscriptions, or manually changing the DOM.

Why Do We Need useEffect?

Without useEffect, your function components would be limited to:

  • Calculating and returning JSX based on props and state
  • Updating state within event handlers

But real applications need to:

  • Connect to external systems
  • Subscribe to events or data streams
  • Clean up resources to prevent memory leaks
  • Synchronize with external state
  • Respond to component lifecycle events

useEffect Made Simple

Imagine a parent giving their child instructions about when to do chores:

  1. The parent gives initial instructions when the child wakes up (component mounts)
  2. The parent updates instructions whenever circumstances change (dependencies update)
  3. The parent provides cleanup instructions before giving new ones (cleanup function)
  4. The parent gives final instructions before the child goes to bed (component unmounts)

This is exactly how useEffect works in React!

Basic Syntax

import { useEffect } from "react";

function MyComponent() {
  useEffect(
    () => {
      // Side effect code runs after render
      console.log("Component rendered");

      // Optional cleanup function
      return () => {
        console.log("Cleaning up");
      };
    },
    [
      /* dependency array */
    ]
  );

  return <div>My Component</div>;
}

Let's break this down:

  1. We import the useEffect hook from React
  2. We call useEffect() with two arguments:
    • A function containing the code for your side effect
    • A dependency array that controls when the effect runs
  3. Optionally, our effect function can return a cleanup function
  4. The effect runs after render and the cleanup runs before the next effect or unmount

Dependency Array Explained

The dependency array is crucial for controlling when your effect runs:

  1. Empty array []: Effect runs only once after the initial render (mount)

    useEffect(() => {
      console.log("Component mounted");
    }, []); // Only on mount
    
  2. With dependencies [value1, value2]: Effect runs after initial render and whenever any dependency changes

    useEffect(() => {
      console.log("Count changed to:", count);
    }, [count]); // When count changes
    
  3. No dependency array: Effect runs after every render

    useEffect(() => {
      console.log("Component rendered");
    }); // After every render
    

Cleanup Function

The cleanup function prevents memory leaks by cleaning up resources before the component unmounts or before the effect runs again:

useEffect(() => {
  // Set up subscription
  const subscription = ExternalAPI.subscribe();

  // Clean up subscription
  return () => {
    subscription.unsubscribe();
  };
}, []);

useEffect in Real Life

Here's a practical example with parent and child components:

import { useState, useEffect } from "react";

// Parent component
function ParentDashboard() {
  const [isOnline, setIsOnline] = useState(true);
  const [userId, setUserId] = useState(1);

  // Toggle online status for demo
  const toggleStatus = () => setIsOnline(!isOnline);

  // Change user for demo
  const changeUser = () => setUserId((id) => id + 1);

  return (
    <div className="dashboard">
      <h1>User Dashboard</h1>
      <div className="controls">
        <button onClick={toggleStatus}>{isOnline ? "Set Offline" : "Set Online"}</button>
        <button onClick={changeUser}>Next User</button>
      </div>

      {/* Pass props to child */}
      <UserProfile userId={userId} isOnline={isOnline} />
    </div>
  );
}

// Child component using useEffect
function UserProfile({ userId, isOnline }) {
  const [userData, setUserData] = useState(null);
  const [lastActivity, setLastActivity] = useState(null);

  // Effect 1: Load user data when userId changes
  useEffect(() => {
    console.log(`Loading data for user ${userId}...`);

    // Simulating data fetching
    const timeoutId = setTimeout(() => {
      setUserData({
        id: userId,
        name: `User ${userId}`,
        email: `user${userId}@example.com`,
      });
    }, 500);

    // Cleanup: Cancel any pending data fetch if userId changes again
    return () => {
      console.log(`Cancelled loading data for user ${userId}`);
      clearTimeout(timeoutId);
    };
  }, [userId]); // Only re-run if userId changes

  // Effect 2: Update activity tracker when online status changes
  useEffect(() => {
    if (!isOnline) {
      console.log("User went offline, stopping activity tracking");
      return;
    }

    console.log("User is online, starting activity tracking");

    // Track last activity time
    const intervalId = setInterval(() => {
      setLastActivity(new Date().toLocaleTimeString());
    }, 5000);

    // Cleanup: Stop tracking when component unmounts or user goes offline
    return () => {
      console.log("Stopping activity tracking");
      clearInterval(intervalId);
    };
  }, [isOnline]); // Only re-run if online status changes

  // Effect 3: Component lifecycle logging
  useEffect(() => {
    console.log("UserProfile component mounted");

    return () => {
      console.log("UserProfile component will unmount");
    };
  }, []); // Empty array = run only on mount/unmount

  if (!userData) {
    return <div>Loading user data...</div>;
  }

  return (
    <div className="user-profile">
      <h2>{userData.name}</h2>
      <p>Email: {userData.email}</p>
      <p>Status: {isOnline ? "Online" : "Offline"}</p>
      {isOnline && lastActivity && <p>Last activity: {lastActivity}</p>}
    </div>
  );
}

In this example:

  1. The parent component manages state and passes it to the child
  2. The child uses three different useEffect hooks for separate concerns
  3. Each effect has its appropriate dependencies
  4. Each effect includes cleanup to prevent memory leaks

Common useEffect Patterns

Running Code Once on Mount

useEffect(() => {
  // Initialize something

  return () => {
    // Clean up when component unmounts
  };
}, []);

Responding to Prop/State Changes

useEffect(() => {
  // Do something when dependencies change
}, [prop1, state1]);

Synchronizing with External Systems

useEffect(() => {
  // Connect to external system
  const connection = external.connect();

  // Disconnect when unmounting
  return () => {
    connection.disconnect();
  };
}, []);

Setting Up Event Listeners

useEffect(() => {
  const handleResize = () => {
    // Update something based on window size
  };

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);

Data Fetching with useEffect: A Historical Note

Historically, useEffect was commonly used for data fetching:

useEffect(() => {
  let isMounted = true;

  const fetchData = async () => {
    setLoading(true);
    try {
      const response = await fetch(`https://api.example.com/users/${userId}`);
      const data = await response.json();

      // Only update state if component is still mounted
      if (isMounted) {
        setData(data);
        setError(null);
      }
    } catch (error) {
      if (isMounted) {
        setError(error.message);
        setData(null);
      }
    } finally {
      if (isMounted) {
        setLoading(false);
      }
    }
  };

  fetchData();

  return () => {
    isMounted = false; // Prevent state updates if component unmounts
  };
}, [userId]);

However, this approach has significant drawbacks:

  • Race conditions are difficult to handle
  • Caching and deduplication require complex custom code
  • Error handling and retry logic must be implemented manually
  • Loading states need separate tracking
  • No built-in support for pagination or infinite scrolling
  • Stale data can be displayed during refetching

Modern Alternatives to useEffect for Data Fetching

Today, specialized libraries provide much better solutions for data fetching:

SWR (Stale-While-Revalidate)

import useSWR from "swr";

function UserProfile({ userId }) {
  const { data, error, isLoading } = useSWR(`https://api.example.com/users/${userId}`, fetcher);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading user data</div>;

  return <div>{data.name}</div>;
}

TanStack Query (React Query)

import { useQuery } from "@tanstack/react-query";

function UserProfile({ userId }) {
  const { data, error, isLoading } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetch(`https://api.example.com/users/${userId}`).then((res) => res.json()),
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading user data</div>;

  return <div>{data.name}</div>;
}

These libraries offer significant advantages over raw useEffect:

  • Automatic caching and deduplication
  • Built-in loading and error states
  • Automatic retries and background refreshes
  • Request cancellation
  • Pagination and infinite scroll support
  • Devtools for debugging
  • Optimistic updates
  • Normalized caches

Common useEffect Mistakes

Missing Dependencies

// WRONG: userId should be in dependency array
useEffect(() => {
  console.log(`User ${userId} profile viewed`);
}, []); // Missing userId dependency

// RIGHT
useEffect(() => {
  console.log(`User ${userId} profile viewed`);
}, [userId]);

Unnecessary Dependencies

// WRONG: Including function that doesn't change
function Component() {
  const formatData = (data) => `Formatted: ${data}`;

  useEffect(() => {
    console.log(formatData("test"));
  }, [formatData]); // formatData is recreated each render
}

// RIGHT: Move function inside effect or use useCallback
function Component() {
  useEffect(() => {
    const formatData = (data) => `Formatted: ${data}`;
    console.log(formatData("test"));
  }, []);
}

Ignoring Cleanup

// WRONG: No cleanup for timer
useEffect(() => {
  const timerId = setInterval(() => {
    console.log("This runs every second");
  }, 1000);
}, []);

// RIGHT: Clean up timer
useEffect(() => {
  const timerId = setInterval(() => {
    console.log("This runs every second");
  }, 1000);

  return () => clearInterval(timerId);
}, []);

Running Effects Unnecessarily

// WRONG: Effect runs on every render
useEffect(() => {
  document.title = `Hello, ${name}`;
}); // No dependency array

// RIGHT: Effect runs only when name changes
useEffect(() => {
  document.title = `Hello, ${name}`;
}, [name]);

Advanced useEffect Techniques

Conditional Effects

While you can't use conditionals around the useEffect call, you can use them inside:

useEffect(() => {
  if (!userId) return; // Skip effect if userId doesn't exist

  fetchUserData(userId);
}, [userId]);

Debouncing Effects

Useful for effects that don't need to run immediately:

useEffect(() => {
  if (searchTerm.trim() === "") return;

  const debounceTimer = setTimeout(() => {
    console.log(`Searching for: ${searchTerm}`);
    performSearch(searchTerm);
  }, 500);

  return () => clearTimeout(debounceTimer);
}, [searchTerm]);

Sequential Effects

When one effect depends on the result of another:

// First fetch the user
const { data: user } = useSWR(`/api/users/${userId}`, fetcher);

// Then fetch the user's posts using the user's data
const { data: posts } = useSWR(user ? `/api/posts?userId=${user.id}` : null, fetcher);

When to Use useEffect

useEffect is ideal for:

  • Synchronizing with external systems (WebSockets, localStorage)
  • Setting up subscriptions and event listeners
  • Integrating with non-React code and third-party libraries
  • Managing component lifecycle (focus, animations, measurements)
  • One-time initializations

But avoid useEffect for:

  • Data fetching (use SWR, TanStack Query, or React Server Components)
  • State updates that could be derived during render
  • User events (use event handlers instead)
  • Performance optimizations
  • Setup that doesn't need cleanup or synchronization

I apologize for missing the exercises section. You're right that I should have included them as in the useState example. Here are three exercises for useEffect with their solutions:

Exercises

Exercise 1: Document Title Updater

Objective: Create a component that updates the document title when a user types into an input field.

Detailed Instructions:

  1. Create a new file called title-updater.tsx in your components folder.
  2. Import React, useState, and useEffect.
  3. Create and export a functional component named TitleUpdater.
  4. Inside your component:
    • Create a state variable called title with an initial value of "React App".
    • Use an input field that updates the title state when the user types.
    • Use useEffect to update the document title whenever the title state changes.
    • Make sure to reset the document title when the component unmounts.
  5. Test that the page title in the browser tab changes as you type.

Starter Code:

import { useState, useEffect } from "react";

const TitleUpdater = () => {
  // Add your state declaration here

  // Add your useEffect here

  return <div>{/* Add your input field here */}</div>;
};

export default TitleUpdater;

Exercise 2: Window Size Tracker

Objective: Create a component that displays the current window width and height, updating when the window is resized.

Detailed Instructions:

  1. Create a new file called window-size-tracker.tsx in your components folder.
  2. Import React and useEffect.
  3. Create and export a functional component named WindowSizeTracker.
  4. Inside your component:
    • Create state variables to store the window's width and height.
    • Use useEffect to:
      • Set the initial width and height when the component mounts.
      • Add an event listener for the window's resize event.
      • Update the state when the window is resized.
      • Clean up the event listener when the component unmounts.
  5. Display the current window size in your component.

Starter Code:

import { useState, useEffect } from "react";

const WindowSizeTracker = () => {
  // Add your state declarations here

  // Add your useEffect here

  return <div>{/* Display the window size here */}</div>;
};

export default WindowSizeTracker;

Exercise 3: Timer Component

Objective: Create a simple timer component that counts up every second and can be started, paused, and reset.

Detailed Instructions:

  1. Create a new file called timer.tsx in your components folder.
  2. Import React, useState, and useEffect.
  3. Create and export a functional component named Timer.
  4. Inside your component:
    • Create a state variable for the elapsed time (in seconds), starting at 0.
    • Create a state variable for the timer status (running or paused).
    • Use useEffect to:
      • Set up an interval that increments the elapsed time every second when the timer is running.
      • Clear the interval when the timer is paused or when the component unmounts.
    • Add buttons to start, pause, and reset the timer.
  5. Display the elapsed time in a readable format (e.g., MM:SS).

Starter Code:

import { useState, useEffect } from "react";

const Timer = () => {
  // Add your state declarations here

  // Add your useEffect here

  // Add helper functions for the buttons

  // Add a function to format the time display

  return <div>{/* Add the timer display and buttons here */}</div>;
};

export default Timer;

Remember

  • Always provide a cleanup function in useEffect when needed
  • Be mindful of your dependency array to prevent infinite loops
  • Use multiple useEffect hooks for separate concerns
  • Always return a function from useEffect for proper cleanup
  • For timers and intervals, always clear them to prevent memory leaks