DEV Community

Cover image for You're Overusing useEffect. Here's How to Fix It.
Bishoy Bishai
Bishoy Bishai

Posted on • Originally published at bishoy-bishai.github.io

You're Overusing useEffect. Here's How to Fix It.

You're Overusing useEffect. Here's How to Fix It.

We've all been there. You open a React component, scroll through line after line, and find yourself in a dense forest of useEffect calls. One useEffect for data fetching, another for updating the document title, a third for setting up a WebSocket connection, and maybe a fourth for animating something on mount. It's easy to fall into the trap of using useEffect as the go-to solution for anything that feels "side-effect-y." But in my experience, this often leads to code that's harder to read, debug, and maintain, ultimately making your team less productive and your application less robust.

Here's the stark truth: if you find yourself constantly battling useEffect's dependency array, or if your components feel brittle and prone to unexpected re-renders, it's a strong signal you might be overusing it. Let's unpack why this happens and, more importantly, how we can build more resilient React applications.

The Mental Model Mismatch: useEffect Isn't componentDidMount (or Update)

The biggest misconception I've encountered is treating useEffect as a direct replacement for lifecycle methods like componentDidMount, componentDidUpdate, or componentWillUnmount. While it can handle those scenarios, its true power, and primary purpose, is synchronization.

Think of useEffect as a way to synchronize your React component with external systems. This could be:

  • The DOM (e.g., manually adding event listeners, updating document.title).
  • The network (e.g., data fetching).
  • Browser APIs (e.g., localStorage, geolocation).
  • Third-party libraries (e.g., integrating a charting library, a video player).

If your useEffect isn't clearly synchronizing with something outside of React's render cycle, there's a good chance you have a simpler, more direct solution available.

Let's dive into some common scenarios where useEffect is often overused and explore better alternatives.

1. Derived State: Don't Recalculate What You Can useMemo

A frequent misuse of useEffect is to compute derived state that depends on other state or props.

The useEffect trap:

function ProductDisplay({ products, filterTerm }: Props) {
  const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);

  useEffect(() => {
    console.log('Recalculating filtered products in useEffect');
    const newFiltered = products.filter(p => p.name.includes(filterTerm));
    setFilteredProducts(newFiltered);
  }, [products, filterTerm]); // Runs on every product/filter change

  return (
    <div>
      {/* Render filteredProducts */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This works, but it's unnecessary overhead. setFilteredProducts triggers another render after the initial render for ProductDisplay.

The fix: useMemo

import React, { useMemo } from 'react';

function ProductDisplay({ products, filterTerm }: Props) {
  const filteredProducts = useMemo(() => {
    console.log('Recalculating filtered products with useMemo');
    return products.filter(p => p.name.includes(filterTerm));
  }, [products, filterTerm]); // Recalculates only when products or filterTerm change

  return (
    <div>
      {/* Render filteredProducts */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useMemo directly computes the value during render and memoizes it, preventing unnecessary state updates and re-renders. It's cleaner, more performant, and expresses intent better.

2. Event Handlers & Local State: Attach Directly or useState

Sometimes, we reach for useEffect to manage simple local state interactions or attach event handlers that don't need a full synchronization loop.

The useEffect trap for local state:

function MyComponent() {
  const [isOpen, setIsOpen] = useState(false);

  // Don't do this!
  useEffect(() => {
    // This is essentially just setting state based on a prop or internal logic
    // which should happen directly or via useState's lazy initializer
  }, [someCondition]); 
  // (Often this pattern morphs into useEffects that set state based on other state)

  // ...
}
Enter fullscreen mode Exit fullscreen mode

If you're just initializing state or updating it based on a direct user interaction, useState or direct event handlers are usually sufficient.

The fix: Direct handlers & useState initializers

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

  // Direct event handler
  const increment = () => setCount(prev => prev + 1);

  return <button onClick={increment}>Count: {count}</button>;
}

function InitializedComponent({ initialValue }: { initialValue: number }) {
  // Lazily initialize state once, no useEffect needed
  const [value, setValue] = useState(() => initialValue * 2); 

  return <div>Value: {value}</div>;
}
Enter fullscreen mode Exit fullscreen mode

For cases where you need to react to a prop change and update local state, consider if key prop re-mounting or a dedicated useReducer with an initializer might be more appropriate than useEffect.

3. Data Fetching (with Caveats): Custom Hooks are Your Friends

This is a big one. While useEffect is suitable for data fetching, especially simple cases, its overuse comes when we embed complex fetching logic, caching, and error handling directly into components.

The useEffect trap:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let ignore = false; // To handle race conditions
    const fetchUser = async () => {
      setIsLoading(true);
      setError(null);
      try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
          throw new Error('Failed to fetch user');
        }
        const data = await response.json();
        if (!ignore) {
          setUser(data);
        }
      } catch (err: any) {
        if (!ignore) {
          setError(err.message);
        }
      } finally {
        if (!ignore) {
          setIsLoading(false);
        }
      }
    };

    fetchUser();

    return () => {
      ignore = true; // Cleanup for race conditions
    };
  }, [userId]); // Re-fetches when userId changes

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

  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is a lot of boilerplate for every data fetch. It tightly couples the fetching logic to the component, making it less reusable and harder to test.

The fix: Custom Hooks

Extract this common logic into a reusable custom hook.

import { useState, useEffect } from 'react';

interface User {
  id: string;
  name: string;
  email: string;
}

interface UseFetchResult<T> {
  data: T | null;
  isLoading: boolean;
  error: string | null;
}

function useFetch<T>(url: string, dependencies: React.DependencyList = []): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let ignore = false;
    const fetchData = async () => {
      setIsLoading(true);
      setError(null);
      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const json = await response.json();
        if (!ignore) {
          setData(json);
        }
      } catch (err: any) {
        if (!ignore) {
          setError(err.message);
        }
      } finally {
        if (!ignore) {
          setIsLoading(false);
        }
      }
    };

    fetchData();

    return () => {
      ignore = true;
    };
  }, [url, ...dependencies]); // Re-runs when URL or explicit dependencies change

  return { data, isLoading, error };
}

// Now, your component becomes much cleaner:
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useFetch<User>(`/api/users/${userId}`, [userId]);

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

  return (
    <div>
      <h2>{user.name}</h2>
      <p>Email: {user.email}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This pattern is a game-changer. It abstracts away the complexity, makes fetching logic reusable, and significantly cleans up your components. Libraries like React Query or SWR take this even further, handling caching, revalidation, and more.

4. Cleanup Logic: Don't Forget the Return

This isn't an "overuse" issue as much as a "misuse" or "neglect" issue, but it's crucial for useEffect to avoid memory leaks and unexpected behavior. If your useEffect sets up anything that needs to be torn down (event listeners, subscriptions, timers), always return a cleanup function.

Example:

useEffect(() => {
  const handleScroll = () => { /* ... */ };
  window.addEventListener('scroll', handleScroll);

  return () => {
    // This runs when the component unmounts OR when dependencies change and the effect re-runs
    window.removeEventListener('scroll', handleScroll);
  };
}, []); // Empty dependency array means it runs once on mount, cleans up on unmount
Enter fullscreen mode Exit fullscreen mode

Neglecting cleanup is a common source of bugs that are hard to track down.

A Mindset Shift: When Is useEffect Appropriate?

After all this, you might be wondering if you should ever use useEffect! Absolutely. It's indispensable when you truly need to synchronize with an external system.

Ask yourself these questions:

  1. Is this logic interacting with something outside of React's render cycle? (e.g., DOM, network, browser API, 3rd-party library)
  2. Does this logic need to run after every render where its dependencies change?
  3. Does this logic need a cleanup mechanism?

If you answer "yes" to these, useEffect is likely the right tool. If not, pause and consider useMemo, useCallback, useState, direct event handlers, or a custom hook.

The Payoff: Cleaner, More Predictable Code

By being more intentional with useEffect, you'll gain:

  • Improved Readability: Components become easier to parse, with concerns clearly separated.
  • Easier Debugging: Fewer places for side effects to hide, making issues simpler to pinpoint.
  • Better Performance: Avoiding unnecessary renders and state updates.
  • Enhanced Reusability: Logic encapsulated in custom hooks can be shared across your application.
  • Reduced Cognitive Load: You and your team can reason about component behavior more quickly.

It's a journey, not a destination. Start by looking at your existing components with a critical eye. Can that useEffect be a useMemo? Can those three effects be combined into a single, well-scoped custom hook? Embrace the simplicity, and your React code will thank you for it. The goal isn't to eliminate useEffect, but to use it wisely, like the powerful precision tool it was designed to be.


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai


Top comments (0)