DEV Community

Krish Kakadiya
Krish Kakadiya

Posted on

An Interactive Guide to How React’s useEffect Really Works

Why useEffect Is Essential for React Developers

If you’ve ever built a React app, you’ve probably reached for useEffect. It’s the Swiss Army knife for handling side effects—things like fetching data, updating the DOM, or setting up subscriptions. But let’s be honest: useEffect can feel like a black box, with dependency arrays and cleanup functions tripping up even experienced developers.
In this guide, you’ll learn exactly how useEffect works under the hood, why it’s critical for modern React apps, and how to use it like a pro. We’ll break it down step by step, with practical examples and visuals to make it crystal clear. By the end, you’ll be confident in managing side effects and avoiding common pitfalls. Let’s dive in!

What Is useEffect, Anyway?

The useEffecthook lets you perform side effects in functional React components. Side effects are anything outside the main render flow—like fetching data, updating the title, or listening to events. Think of useEffectas React’s way of saying, “Hey, do this stuff after rendering.”
Here’s the basic syntax:

import { useEffect } from 'react';

useEffect(() => {
  // Your side effect code here
}, [/* dependencies */]);
Enter fullscreen mode Exit fullscreen mode

The first argument is a callback function with your side effect. The second argument, the dependency array, tells React when to re-run the effect. If you skip the array, the effect runs after every render. If it’s empty ([]), it runs once after the initial render.
Why does this matter? In real apps, side effects are everywhere—API calls, timers, or DOM updates. useEffectkeeps them organized and ensures they happen at the right time.

Gotcha Alert: Forgetting the dependency array (or including the wrong dependencies) can lead to infinite loops or stale data. Always double-check what’s in that array!

Your First useEffect Hook

Let’s start with a simple example: updating the page title based on a component’s state.

import { useState, useEffect } from 'react';

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

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]);

  return (
    <button onClick={() => setCount(count + 1)}>
      Clicked {count} times
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

What’s happening here?

  1. The useEffecthook runs after every render where countchanges.
  2. It updates the document title with the current count.
  3. The [count] dependency array ensures the effect only runs when count updates.

Try clicking the button, and watch the browser tab’s title change. It’s a small but powerful example of useEffectin action.

Tip Box: If your effect doesn’t depend on any props or state, use an empty array ([]) to run it only once, like componentDidMount in class components. This is great for one-time setups like fetching initial data.
[Insert Interactive Demo Screenshot / CodePen Example Here]

Visualizing useEffect’s Lifecycle

To master useEffect, you need to understand when it runs. Think of it as part of React’s lifecycle for functional components:

1. Render: React renders your component and updates the DOM.
2. Effect: After rendering, useEffect runs your callback (if dependencies changed).
3. Cleanup (optional): If you return a cleanup function from useEffect, it runs before the next effect or when the component unmounts.

Here’s a visual timeline for our TitleUpdater:

You can debug this in React DevTools by watching the component’s render cycle and effect triggers. Add console.log inside your effect to see when it fires.

Gotcha Alert: If you forget to return a cleanup function for subscriptions (like timers or event listeners), you can create memory leaks. Always clean up after yourself!

Real-World Use Case: Building a Data-Fetching Component
Let’s build something practical: a component that fetches and displays a list of todos from an API. This is a common pattern in React apps.

import { useState, useEffect } from 'react';

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchTodos() {
      try {
        const response = await fetch('https://jsonplaceholder.typicode.com/todos');
        if (!response.ok) throw new Error('Failed to fetch todos');
        const data = await response.json();
        setTodos(data.slice(0, 5)); // Limit to 5 todos
        setLoading(false);
      } catch (err) {
        setError(err.message);
        setLoading(false);
      }
    }

    fetchTodos();
  }, []); // Empty array: run once on mount

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component:

  1. Fetches todos when it mounts (empty dependency array).
  2. Handles loading and error states for a better UX.
  3. Renders a list of todos once data is available.

Accessibility Note: Ensure your loading and error states are accessible. Use aria-live="polite" on the container to notify screen readers of updates.
[Insert Interactive Todo List Demo Here]

Tip Box: Always include loading and error states when fetching data. Users hate staring at a blank screen, and errors can happen unexpectedly.

Advanced useEffect Techniques

Let’s level up with two advanced useEffectpatterns:

1. Cleanup for Subscriptions: Use a cleanup function to prevent memory leaks, like clearing a timer

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Tick');
  }, 1000);

  return () => clearInterval(timer); // Cleanup on unmount or re-run
}, []);
Enter fullscreen mode Exit fullscreen mode

The cleanup function runs before the next effect or when the component unmounts.

2. Conditional Effects: Run effects only when specific conditions are met:

useEffect(() => {
  if (userId) {
    async function fetchUser() {
      const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
      const user = await response.json();
      setUser(user);
    }
    fetchUser();
  }
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

This ensures the effect only runs when userId exists, avoiding unnecessary API calls.

Gotcha Alert: Be careful with async functions inside useEffect. Since useEffectdoesn’t accept Promises, always define the async function inside and call it immediately.

Common useEffect Mistakes

useEffect is powerful but easy to misuse. Here are common pitfalls:

1. Missing Dependencies:
Omitting a dependency from the array can cause stale data or bugs. Use ESLint’s react-hooks/exhaustive-deps rule to catch this.

useEffect(() => {
  console.log(myProp); // Forgot to include myProp in dependencies!
}, []); // Bug: myProp won’t update
Enter fullscreen mode Exit fullscreen mode

2. Over-Running Effects:
Without a dependency array, your effect runs on every render, which can kill performance.

3. Ignoring Cleanup:
Forgetting to clean up subscriptions (like timers or WebSockets) can cause memory leaks or duplicate events.

Tip Box: Use React DevTools to inspect how often your effects run. If you see too many logs, double-check your dependency array.

Wrapping Up
The useEffecthook is your key to managing side effects in React. In this guide, you learned:

  • How useEffect handles side effects like DOM updates and API calls.
  • A practical example of updating the page title.
  • How to visualize the effect lifecycle and debug with DevTools.
  • A real-world data-fetching component with loading and error states.
  • Advanced techniques like cleanup and conditional effects.
  • Common mistakes to avoid, like missing dependencies.

Now, try building a component that uses useEffect—maybe a live clock or a search bar with debounced API calls. Experiment with cleanup functions to keep your app lean.
Got a cool useEffectexample? Share it in the comments.

Top comments (0)