AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

useEffect Hook: 6 Common Mistakes Beginners Make

Last updated:
Common useEffect Mistakes and How to Fix Them

Master useEffect by learning what not to do. Understand dependency arrays, cleanup functions, async patterns, and the Rules of Hooks with complete code examples.

# useEffect Hook: 6 Common Mistakes Beginners Make

When you're learning React, useEffect is one of the first hooks you'll encounter. It's powerful, but also deceptively easy to misuse. I've seen countless developers struggle with infinite loops, forgotten cleanup functions, and mysterious bugs that only appear in production. The good news? Most mistakes follow predictable patterns. Once you understand what goes wrong and why, you'll write cleaner, more reliable side effects.

This guide walks through the six most common useEffect mistakes that catch beginners off guard, with practical fixes and real-world examples you can apply immediately.

# Table of Contents

  1. Mistake 1: Omitting or Misunderstanding the Dependency Array
  2. Mistake 2: Using Async Functions Directly
  3. Mistake 3: Forgetting to Clean Up
  4. Mistake 4: Violating the Rules of Hooks
  5. Mistake 5: Creating Infinite Loops
  6. Mistake 6: Treating Dependencies Carelessly

# Mistake 1: Omitting the Dependency Array

# The Problem

Many beginners write useEffect without understanding what the dependency array actually does. Without it, your effect runs on every render—which can tank performance and cause bugs you won't catch until production.

# Why It Happens

The dependency array isn't intuitive. You might think "I only defined my effect once, why would it run multiple times?" But React's design means your component function runs on every render, including all the code inside it. The dependency array is what tells React "only run this effect when these values actually change."

# The Wrong Way

typescript
import { useState, useEffect } from 'react';

export function UserProfile() {
  const [user, setUser] = useState(null);

  // ❌ WRONG: Effect runs on every render
  useEffect(() => {
    fetch('/api/user').then(r => r.json()).then(setUser);
  }); // No dependency array!

  return <div>{user?.name}</div>;
}

In this example, the effect runs after every render. This means it fetches user data infinitely, hammering your API and creating unnecessary network requests. Your browser's network tab will look like a waterfall of identical requests.

# The Right Way

typescript
import { useState, useEffect } from 'react';

export function UserProfile() {
  const [user, setUser] = useState(null);

  // ✅ CORRECT: Effect runs once on mount
  useEffect(() => {
    fetch('/api/user').then(r => r.json()).then(setUser);
  }, []); // Empty array = run once

  return <div>{user?.name}</div>;
}
javascript
import { useState, useEffect } from 'react';

export function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch('/api/user').then(r => r.json()).then(setUser);
  }, []);

  return <div>{user?.name}</div>;
}

The empty dependency array [] tells React: "Run this effect once when the component mounts, then never again." This is the pattern you'll use for one-time initialization tasks like fetching initial data.

# When Dependencies Matter

Sometimes you want the effect to run again when something changes:

typescript
import { useState, useEffect } from 'react';

interface UserSearchProps {
  userId: string;
}

export function UserSearch({ userId }: UserSearchProps) {
  const [userData, setUserData] = useState(null);

  // ✅ CORRECT: Re-run when userId changes
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUserData);
  }, [userId]); // Include userId in dependencies

  return <div>{userData?.email}</div>;
}
javascript
export function UserSearch({ userId }) {
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(setUserData);
  }, [userId]);

  return <div>{userData?.email}</div>;
}

Now when userId changes, the effect runs again and fetches the new user's data. Without userId in the dependencies, the component would show stale data.


# Mistake 2: Using Async Functions Directly

# The Problem

You might instinctively write useEffect(async () => { ... }) because you want to fetch data asynchronously. But React explicitly forbids this pattern—and raises a warning if you try.

# Why It Happens

The logic seems sound: "I need async/await, so I'll mark the effect function as async." But useEffect expects its function to return either nothing or a cleanup function. An async function returns a Promise, which breaks that contract. React's warning tells you this pattern doesn't work.

# The Wrong Way

typescript
import { useState, useEffect } from 'react';

export function DataFetcher() {
  const [data, setData] = useState(null);

  // ❌ WRONG: Effect function marked as async
  useEffect(async () => {
    const response = await fetch('/api/data');
    const result = await response.json();
    setData(result);
  }, []);

  return <div>{data?.id}</div>;
}

React's warning in the browser console:

typescript
Warning: useEffect must not return anything other than a function, 
which is used for clean-up. It looks like you wrote useEffect(async () => ...) 
or returned a Promise.

# The Right Way (Two Patterns)

Pattern 1: Wrap async code in a function and call it

typescript
import { useState, useEffect } from 'react';

export function DataFetcher() {
  const [data, setData] = useState(null);

  // ✅ CORRECT: Create async function inside effect
  useEffect(() => {
    async function fetchData() {
      const response = await fetch('/api/data');
      const result = await response.json();
      setData(result);
    }

    fetchData();
  }, []);

  return <div>{data?.id}</div>;
}
javascript
import { useState, useEffect } from 'react';

export function DataFetcher() {
  const [data, setData] = useState(null);

  useEffect(() => {
    async function fetchData() {
      const response = await fetch('/api/data');
      const result = await response.json();
      setData(result);
    }

    fetchData();
  }, []);

  return <div>{data?.id}</div>;
}

Pattern 2: Use .then() chains (if you prefer avoiding async/await)

typescript
import { useState, useEffect } from 'react';

export function DataFetcher() {
  const [data, setData] = useState(null);

  // ✅ CORRECT: Using promises directly
  useEffect(() => {
    fetch('/api/data')
      .then(r => r.json())
      .then(setData);
  }, []);

  return <div>{data?.id}</div>;
}

Both patterns avoid marking the effect function itself as async. The effect function stays synchronous and returns nothing (or a cleanup function), which is what React expects.


# Mistake 3: Forgetting to Clean Up

# The Problem

Many side effects create resources—event listeners, timers, subscriptions, open connections—that need to be cleaned up when the component unmounts or before the effect runs again. Forgetting this causes memory leaks, duplicate listeners, and performance degradation.

# Why It Happens

Cleanup functions feel optional. Your code might work fine in development, but memory leaks grow silently in production. Users navigate between pages, components mount and unmount hundreds of times, and suddenly your app is consuming gigabytes of memory.

# The Wrong Way

typescript
import { useEffect } from 'react';

export function ClickTracker() {
  // ❌ WRONG: Event listener is never removed
  useEffect(() => {
    const handleClick = () => {
      console.log('Document clicked');
    };

    document.addEventListener('click', handleClick);
  }, []);

  return <div>Open your console and click around</div>;
}

If this component mounts and unmounts (for example, when you navigate to a different page), the event listener stays attached to the document. Revisit the page? Another listener gets added. Do this ten times and you have ten listeners, all firing on every click. Your app becomes increasingly sluggish.

# The Right Way

typescript
import { useEffect } from 'react';

export function ClickTracker() {
  // ✅ CORRECT: Cleanup function removes the listener
  useEffect(() => {
    const handleClick = () => {
      console.log('Document clicked');
    };

    document.addEventListener('click', handleClick);

    // Return a cleanup function
    return () => {
      document.removeEventListener('click', handleClick);
    };
  }, []);

  return <div>Open your console and click around</div>;
}
javascript
import { useEffect } from 'react';

export function ClickTracker() {
  useEffect(() => {
    const handleClick = () => {
      console.log('Document clicked');
    };

    document.addEventListener('click', handleClick);

    return () => {
      document.removeEventListener('click', handleClick);
    };
  }, []);

  return <div>Open your console and click around</div>;
}

The cleanup function returned from useEffect runs:

  • When the component unmounts
  • Before the effect runs again (if dependencies changed)

Common cleanup patterns:

typescript
import { useState, useEffect } from 'react';

export function TimerExample() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // ✅ Cleanup for timers
    const interval = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

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

  return <div>Elapsed: {seconds}s</div>;
}
typescript
import { useState, useEffect } from 'react';

export function SubscriptionExample() {
  const [status, setStatus] = useState('idle');

  useEffect(() => {
    // ✅ Cleanup for subscriptions
    const unsubscribe = window.addEventListener('online', () => {
      setStatus('online');
    });

    return () => {
      if (unsubscribe) unsubscribe();
    };
  }, []);

  return <div>Status: {status}</div>;
}

# Mistake 4: Violating the Rules of Hooks

# The Problem

The Rules of Hooks are non-negotiable. Break them and your component's behavior becomes unpredictable. React's Hook calling order depends on the order of the code, and changing that order (like calling a Hook conditionally) breaks that system.

# Rule 1: Don't Call Hooks Conditionally

typescript
import { useState, useEffect } from 'react';

interface ConditionalEffectProps {
  shouldFetch: boolean;
}

export function ConditionalEffect({ shouldFetch }: ConditionalEffectProps) {
  const [data, setData] = useState(null);

  // ❌ WRONG: Hook called conditionally
  if (shouldFetch) {
    useEffect(() => {
      fetch('/api/data').then(r => r.json()).then(setData);
    }, []);
  }

  return <div>{data?.id}</div>;
}

This violates the Rules of Hooks. The effect might or might not be set up depending on shouldFetch, causing React to lose track of your hooks.

The fix: Move the condition inside the effect

typescript
import { useState, useEffect } from 'react';

interface ConditionalEffectProps {
  shouldFetch: boolean;
}

export function ConditionalEffect({ shouldFetch }: ConditionalEffectProps) {
  const [data, setData] = useState(null);

  // ✅ CORRECT: Hook always runs, condition inside
  useEffect(() => {
    if (!shouldFetch) return; // Early exit instead of conditional hook

    fetch('/api/data').then(r => r.json()).then(setData);
  }, [shouldFetch]);

  return <div>{data?.id}</div>;
}
javascript
export function ConditionalEffect({ shouldFetch }) {
  const [data, setData] = useState(null);

  useEffect(() => {
    if (!shouldFetch) return;

    fetch('/api/data').then(r => r.json()).then(setData);
  }, [shouldFetch]);

  return <div>{data?.id}</div>;
}

# Rule 2: Don't Call Hooks Inside Nested Functions

typescript
import { useState, useEffect } from 'react';

export function NestedHookExample() {
  // ❌ WRONG: Calling useEffect in an event handler
  const handleClick = () => {
    useEffect(() => {
      console.log('Clicked');
    });
  };

  return <button onClick={handleClick}>Click me</button>;
}

The hook runs inside handleClick, not at the top level of the component. React can't track it properly.

The fix: Set up the effect at the component level and use state in the handler

typescript
import { useState, useEffect } from 'react';

export function NestedHookExample() {
  const [clicked, setClicked] = useState(false);

  // ✅ CORRECT: useEffect at component level
  useEffect(() => {
    if (clicked) {
      console.log('Component was clicked');
    }
  }, [clicked]);

  const handleClick = () => {
    setClicked(true);
  };

  return <button onClick={handleClick}>Click me</button>;
}
javascript
import { useState, useEffect } from 'react';

export function NestedHookExample() {
  const [clicked, setClicked] = useState(false);

  useEffect(() => {
    if (clicked) {
      console.log('Component was clicked');
    }
  }, [clicked]);

  const handleClick = () => {
    setClicked(true);
  };

  return <button onClick={handleClick}>Click me</button>;
}

# Mistake 5: Creating Infinite Loops

# The Problem

The most common infinite loop happens when you use state in the effect's dependency array but update that same state inside the effect. Every state update triggers the effect again, which updates the state, which triggers the effect... forever.

# The Wrong Way

typescript
import { useState, useEffect } from 'react';

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

  // ❌ WRONG: Infinite loop
  useEffect(() => {
    setCount(count + 1); // Updates count
  }, [count]); // Re-runs effect when count changes

  return <div>Count: {count}</div>;
}

What happens:

  1. Component mounts, effect runs
  2. setCount(count + 1) updates count to 1
  3. Since count changed, effect runs again
  4. setCount(count + 1) updates count to 2
  5. Since count changed, effect runs again
  6. ... repeat forever

Your component will render at maximum speed until React crashes or hangs.

# The Right Way

If you want to increment on mount, remove the dependency:

typescript
import { useState, useEffect } from 'react';

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

  // ✅ CORRECT: Runs once on mount
  useEffect(() => {
    setCount(c => c + 1); // Use updater function
  }, []); // No dependency on count

  return <div>Count: {count}</div>;
}
javascript
export function CorrectMount() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(c => c + 1);
  }, []);

  return <div>Count: {count}</div>;
}

If you need to track state changes externally:

typescript
import { useState, useEffect } from 'react';

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

  // ✅ CORRECT: Reacts to count changes without causing infinite loop
  useEffect(() => {
    console.log('Count changed to:', count);
    // Do something in response to count, but DON'T update count itself
  }, [count]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

# Mistake 6: Treating Dependencies Carelessly

# The Problem

Missing or incorrect dependencies cause effects to run at the wrong times or use stale values. This is subtle—your code might work in most cases but fail in specific scenarios you didn't test.

# The Wrong Way

typescript
import { useState, useEffect } from 'react';

interface SearchProps {
  query: string;
}

export function Search({ query }: SearchProps) {
  const [results, setResults] = useState([]);

  // ❌ WRONG: Missing query in dependencies
  useEffect(() => {
    if (!query) return;

    fetch(`/api/search?q=${query}`)
      .then(r => r.json())
      .then(setResults);
  }, []); // Missing query!

  return (
    <ul>
      {results.map(r => <li key={r.id}>{r.title}</li>)}
    </ul>
  );
}

Here, the effect runs once on mount and never again, even when query changes. The search always shows results for the initial query.

# The Right Way

typescript
import { useState, useEffect } from 'react';

interface SearchProps {
  query: string;
}

export function Search({ query }: SearchProps) {
  const [results, setResults] = useState([]);

  // ✅ CORRECT: query included in dependencies
  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    fetch(`/api/search?q=${query}`)
      .then(r => r.json())
      .then(setResults);
  }, [query]); // Include query in dependencies

  return (
    <ul>
      {results.map(r => <li key={r.id}>{r.title}</li>)}
    </ul>
  );
}
javascript
export function Search({ query }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    fetch(`/api/search?q=${query}`)
      .then(r => r.json())
      .then(setResults);
  }, [query]);

  return (
    <ul>
      {results.map(r => <li key={r.id}>{r.title}</li>)}
    </ul>
  );
}

Now when query changes, the effect re-runs and fetches results for the new query.

# Objects and Functions in Dependencies

Be especially careful with object and function dependencies. A new object or function created on every render will trigger the effect on every render:

typescript
import { useState, useEffect } from 'react';

export function ProblematicConfig() {
  const [data, setData] = useState(null);

  // ❌ WRONG: Config object created on every render
  useEffect(() => {
    const config = { headers: { 'Content-Type': 'application/json' } };
    fetch('/api/data', config)
      .then(r => r.json())
      .then(setData);
  }, [config]); // But config is new every render!
}

Fix: Move objects outside the effect or memoize them

typescript
import { useState, useEffect } from 'react';

export function CorrectConfig() {
  const [data, setData] = useState(null);

  // ✅ CORRECT: Config object defined outside effect
  const config = { headers: { 'Content-Type': 'application/json' } };

  useEffect(() => {
    fetch('/api/data', config)
      .then(r => r.json())
      .then(setData);
  }, []); // Stable reference

  return <div>{data?.id}</div>;
}

# Practical Application: Real-World User Profile Component

Consider a practical scenario common in e-commerce platforms (like Alibaba or ByteDance internal dashboards): Building a user profile component that fetches user data when a user ID prop changes and cleans up subscriptions.

typescript
import { useState, useEffect } from 'react';
import { ReactNode } from 'react';

interface UserProfile {
  id: string;
  name: string;
  email: string;
  preferences: {
    notifications: boolean;
    theme: 'light' | 'dark';
  };
}

interface UserProfileCardProps {
  userId: string;
  onLoadComplete?: () => void;
  children?: ReactNode;
}

export function UserProfileCard({ userId, onLoadComplete, children }: UserProfileCardProps) {
  const [profile, setProfile] = useState<UserProfile | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // Guard against empty userId
    if (!userId) {
      setProfile(null);
      setError(null);
      return;
    }

    setLoading(true);
    setError(null);

    // Create abort controller for cleanup
    const controller = new AbortController();

    // Async function wrapped inside effect
    async function fetchUserProfile() {
      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`Failed to load user: ${response.status}`);
        }

        const data: UserProfile = await response.json();
        setProfile(data);
        onLoadComplete?.();
      } catch (err) {
        // Ignore abort errors (component unmounted)
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchUserProfile();

    // Cleanup: Abort ongoing requests
    return () => {
      controller.abort();
    };
  }, [userId, onLoadComplete]);

  if (loading) return <div>Loading profile...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!profile) return <div>No profile loaded</div>;

  return (
    <div className="profile-card">
      <h2>{profile.name}</h2>
      <p>Email: {profile.email}</p>
      <p>Theme: {profile.preferences.theme}</p>
      {children}
    </div>
  );
}
javascript
import { useState, useEffect } from 'react';

export function UserProfileCard({ userId, onLoadComplete, children }) {
  const [profile, setProfile] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!userId) {
      setProfile(null);
      setError(null);
      return;
    }

    setLoading(true);
    setError(null);

    const controller = new AbortController();

    async function fetchUserProfile() {
      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`Failed to load user: ${response.status}`);
        }

        const data = await response.json();
        setProfile(data);
        onLoadComplete?.();
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchUserProfile();

    return () => {
      controller.abort();
    };
  }, [userId, onLoadComplete]);

  if (loading) return <div>Loading profile...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!profile) return <div>No profile loaded</div>;

  return (
    <div className="profile-card">
      <h2>{profile.name}</h2>
      <p>Email: {profile.email}</p>
      <p>Theme: {profile.preferences.theme}</p>
      {children}
    </div>
  );
}

Key patterns demonstrated:

  • Proper async handling with cleanup wrapper
  • AbortController for request cancellation (prevents updating unmounted components)
  • Guard clause for empty data (if (!userId) return;)
  • Error handling with AbortError filtering
  • Dependencies array includes all used variables
  • Optional callback fires only on successful load

# FAQ

# Q: When should I use useEffect vs. just putting code in the component body?

A: Code in the component body runs on every render. Use useEffect for side effects—anything that's not directly computing render output. Examples: fetching data, setting up event listeners, modifying the DOM, managing subscriptions. Keep the component body for pure calculations only.

# Q: Why does React warn about missing dependencies?

A: Missing dependencies mean your effect is using outdated values. You might fetch data with an old query, access state from three renders ago, or call an event handler that doesn't exist anymore. React's warning helps you catch these bugs before users encounter them.

# Q: Can I safely ignore ESLint warnings about missing dependencies?

A: Rarely. There are legitimate exceptions (like when you're intentionally using a stable callback), but they require explicit comment overrides. If you're tempted to ignore the warning, first ask: "Why is this dependency missing?" Usually, adding it is the right answer.

# Q: What's the difference between returning undefined and returning a cleanup function?

A: Both are valid. If your effect doesn't create resources to clean up, returning nothing is fine. If it creates listeners, timers, or subscriptions, return a function that cleans them up. Think of it as: "For every resource I create, I need a cleanup function to destroy it."

# Q: Do I need to worry about cleanup if I'm just fetching data with fetch()?

A: Not for the fetch itself, but you should use AbortController to cancel in-flight requests when the component unmounts or dependencies change. This prevents "can't update a component in an unmounted state" warnings and wasted network traffic.



Questions? Share your useEffect struggles in the comments below. What mistakes have you made? What patterns have worked best for you? I'd love to hear about real-world scenarios you've encountered and how you solved them.

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT