The Complete Guide to useEffect in 2024

Paul Newsam
ReactWeb PerformanceTechnical

A modern guide to React's useEffect hook, focusing on when you actually need it and when you don't. Learn about modern alternatives to common useEffect patterns, legitimate use cases for synchronizing with external systems, and essential safety principles when useEffect is necessary.

The Complete Guide to useEffect in 2024

Part 1: Why useEffect is Less Common Now

It's easy to forget just how needed useEffect was when it first shipped. Before useEffect and the React Hooks paradigm, React developers had to rely on class components, and they juggled multiple lifecycle methods to manage side effects:

// Before: Scattered logic in class components
class UserProfile extends Component {
  componentDidMount() {
    this.fetchUser(this.props.userId);
  }

  componentDidUpdate(prevProps) {
    if (prevProps.userId !== this.props.userId) {
      this.fetchUser(this.props.userId);
    }
  }

  componentWillUnmount() {
    this.abort.cancel();
  }
}

// After: Unified logic with useEffect
function UserProfile({ userId }) {
  useEffect(() => {
    fetchUser(userId);
    return () => abort.cancel();
  }, [userId]);
}

This approach was cumbersome. Besides requiring a lot of boilerplate code, it split up logic that felt logically related. When useEffect came along, it was a huge improvement on this. Suddenly we had a single, coherent API for managing side effects.

Since then, the React ecosystem has continued to evolve. And many of the use-cases for useEffect have now been displaced by libraries. In 2024, you might not need useEffect at all.

Data Fetching: The Most Common Case

Data fetching was perhaps the most common use-case for useEffect. Developers would typically write something like this:

// 🔴 Old approach with useEffect
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUser).catch(setError);
  }, [userId]);
  // Problems: Race conditions, cleanup complexity,
  // no caching, no automatic revalidation
}

And that's not bad per se. But it turns out that you need a whole lot more code than that in practice. In addition to error state, you usually need to manage loading state. You also usually need some caching logic. And in case of failures, you need to implement retries. The list goes on.

Because data fetching is pretty involved, and because the requirements are highly stereotyped, a number of libraries have evolved to solve this problem. Tanstack Query, SWR, and Apollo are a few of the most popular ones.

For data fetching, you're much better off using one of these libraries than using useEffect directly. It saves you a whole lot of boilerplate code, and solves for use-cases that you might not even be thinking about.

// ✅ Modern approach with React Query/SWR
function UserProfile({ userId }) {
  const { data: user, error } = useQuery(["user", userId], () =>
    fetchUser(userId)
  );
  // Benefits: Built-in caching, revalidation,
  // race condition handling, loading states
}

Other Cases Where useEffect is Unnecessary

While data fetching is the most obvious case where libraries have displaced useEffect, there are other patterns where useEffect is often used unnecessarily:

Transforming Data Based on Props

A common mistake is using useEffect to transform data when props change:

// 🔴 Unnecessary useEffect
function ProductList({ products, filterText }) {
  const [filteredProducts, setFilteredProducts] = useState([]);

  useEffect(() => {
    setFilteredProducts(
      products.filter((product) => product.name.includes(filterText))
    );
  }, [products, filterText]);
}

This pattern adds unnecessary complexity and potential bugs. If the data can be computed directly from props, just do it during render:

// ✅ Better: Transform during render
function ProductList({ products, filterText }) {
  const filteredProducts = products.filter((product) =>
    product.name.includes(filterText)
  );
}

This approach is simpler, has fewer bugs, and is often more performant since it doesn't trigger an extra render. If performance becomes a concern with expensive computations, you can reach for useMemo rather than triggering effects:

// ✅ Better: With memoization for expensive computations
function ProductList({ products, filterText }) {
  const filteredProducts = useMemo(
    () => products.filter((product) => product.name.includes(filterText)),
    [products, filterText]
  );
}

The key principle here is that if you can compute something during render, you should. useEffect is for side effects, not for data transformation.

Part 2: When useEffect is Still the Right Tool

So when should you use useEffect? The answer is pretty straightforward: when you need to synchronize React with something outside of React. Let's look at some legitimate use cases:

1. Syncing with External Systems

The most common valid use case for useEffect is when you need to connect React to some external system - like a browser API, a third-party library, or a native platform feature:

function VideoPlayer({ src }) {
  const videoRef = useRef(null);

  useEffect(() => {
    videoRef.current.play().catch(console.error);
    return () => videoRef.current.pause();
  }, []);

  return <video ref={videoRef} src={src} />;
}

This is a perfect use case for useEffect because we're synchronizing with the browser's video API. There's no library that could abstract this away because it's specific to our application's needs.

2. Managing Non-React State

Sometimes you need to sync React state with some non-React state, like a third-party library's internal state:

function MapComponent({ center }) {
  const mapRef = useRef(null);

  useEffect(() => {
    if (!mapRef.current) return;
    // Sync React prop with the map's internal state
    mapRef.current.setView(center);
  }, [center]);

  return <div ref={mapRef} className="map" />;
}

In this case, the map library maintains its own state about the center position. We use useEffect to keep that internal state in sync with our React props.

3. One-time Side Effects

Sometimes you need to run a side effect exactly once when a component mounts. While this should be rare, it's occasionally necessary:

function Analytics() {
  useEffect(() => {
    logPageView();
  }, []); // Empty dependency array = run once on mount

  return null;
}

Just be careful with this pattern. Often, what seems like a one-time setup actually needs to respond to prop or state changes. Make sure you're not missing necessary dependencies in your dependency array.

Each of these cases shares a common theme: they involve synchronizing React with something outside of React's control. That's the key to identifying when useEffect is appropriate. If you're just managing data flow within React, there's probably a better solution.

Part 3: Using useEffect Safely

When you do need useEffect, there are some key principles to follow to avoid common pitfalls.

The Dependency Array is Your Friend... and Enemy

The dependency array is useEffect's way of knowing when to re-run. Missing dependencies can cause subtle bugs, while unnecessary dependencies can cause infinite loops:

function ChatRoom({ roomId, theme }) {
  // 🔴 Bad: Missing dependency
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.setTheme(theme); // theme is used but not in deps
    connection.connect();
  }, [roomId]); // Missing 'theme'

  // ✅ Good: All dependencies included
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.setTheme(theme);
    connection.connect();
  }, [roomId, theme]);
}

A good rule of thumb: if you're using a value inside your effect, it probably needs to be in your dependency array. The ESLint plugin for React hooks will help catch these issues.

Sometimes your effect will depend on functions or objects that are recreated every render. This can cause your effect to run more often than necessary:

function ChatRoom({ roomId }) {
  // 🔴 Bad: onMessage recreated every render
  const onMessage = (message) => {
    console.log(message, roomId);
  };

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("message", onMessage);
    return () => connection.off("message", onMessage);
  }, [roomId, onMessage]); // Effect runs on every render!

  // ✅ Good: Callback memoized
  const onMessage = useCallback(
    (message) => {
      console.log(message, roomId);
    },
    [roomId]
  );

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("message", onMessage);
    return () => connection.off("message", onMessage);
  }, [roomId, onMessage]); // Effect only runs when roomId changes
}

Use useCallback for functions and useMemo for objects or expensive calculations when they're dependencies of your effects. This ensures your effect only reruns when the underlying values actually change.

Always Clean Up After Yourself

Effects often create resources or subscriptions. When you do this, you need to clean them up:

function ChatRoom({ roomId }) {
  // 🔴 Bad: No cleanup
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
  }, [roomId]);

  // ✅ Good: Cleanup included
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

The cleanup function runs before the effect runs again and when the component unmounts. This prevents memory leaks and ensures resources are properly released.

Race Conditions are Real

When dealing with async operations, race conditions can occur if props or state change before the operation completes:

function UserProfile({ userId }) {
  const [data, setData] = useState(null);

  // 🔴 Bad: Race condition possible
  useEffect(() => {
    fetchUser(userId).then(setData);
  }, [userId]);

  // ✅ Good: Race condition handled
  useEffect(() => {
    let isCurrent = true;

    async function fetchData() {
      const result = await fetchUser(userId);
      if (isCurrent) {
        setData(result);
      }
    }

    fetchData();
    return () => {
      isCurrent = false;
    };
  }, [userId]);
}

Keep Your Effects Focused

Each effect should do one thing. If you find yourself coordinating multiple pieces of state or handling multiple side effects, split them up:

// 🔴 Bad: Effect doing multiple things
useEffect(() => {
  document.title = title;
  analytics.pageview();
  const connection = createConnection();
  connection.connect();
}, [title]);

// ✅ Good: Split into focused effects
useEffect(() => {
  document.title = title;
}, [title]);

useEffect(() => {
  analytics.pageview();
}, []);

useEffect(() => {
  const connection = createConnection();
  connection.connect();
  return () => connection.disconnect();
}, []);

This makes your code easier to understand, test, and maintain. Each effect has a clear, single responsibility.

Conclusion

The React ecosystem has evolved significantly since useEffect was introduced. While it was a huge improvement over class lifecycle methods, today there are better solutions for most use cases:

  1. Start by asking if you really need useEffect:

    • For data fetching, use Tanstack Query or SWR
    • For data transformations, just do them during render
    • For most other cases, there's probably a specialized tool
  2. Only reach for useEffect when synchronizing with external systems. When you do:

    • Clean up your effects
    • Handle race conditions
    • Keep dependencies minimal with useCallback when needed

The key shift in modern React is treating useEffect as a last resort rather than a first choice.