TL;DR: useEffect runs code after React renders your component. It's for side effects — things that reach outside your component like API calls, timers, event listeners, and DOM manipulation. It has three patterns: run every render (no dependency array), run once on mount (empty []), or run when specific values change ([dep1, dep2]). It can return a cleanup function to prevent memory leaks. It is, without question, the single most important React hook you'll encounter in AI-generated code.

Why AI Coders Need This

Open any AI-generated React component. Count the hooks. You'll see useState and useEffect in almost every single one. That's not a coincidence — useEffect is how React components do anything interesting. Without it, a component can only display data. With it, a component can fetch data from an API, listen for keyboard shortcuts, sync with localStorage, update the page title, connect to a WebSocket, start a timer, and a hundred other things.

Here's the problem: useEffect is also the hook that causes the most bugs. Infinite loops, memory leaks, stale data, race conditions — these are all useEffect problems. And AI generates them constantly.

When Cursor or Claude builds you a dashboard that fetches user data, it's using useEffect. When that dashboard keeps re-fetching endlessly and your API bill spikes, that's useEffect too. Understanding this one hook prevents more bugs than any other single concept in React.

What Is a Side Effect?

A React component has one job: take data in, return UI out. That's it. Given the same props and state, it should always return the same HTML. This is called being "pure."

But real apps can't be pure. They need to:

  • Fetch data from an API when the component loads
  • Set up event listeners (keyboard shortcuts, scroll tracking, resize handlers)
  • Read/write localStorage to persist user preferences
  • Update document.title so the browser tab shows the page name
  • Start timers (countdowns, polling, auto-save)
  • Connect to WebSockets for real-time updates
  • Interact with browser APIs (geolocation, notifications, clipboard)

All of these reach outside the component. They talk to the browser, the network, or the DOM. React calls these "side effects" — and useEffect is the designated place to put them.

Think of it this way: your component's return statement is a recipe for what to display. useEffect is everything else — the phone calls, the deliveries, the setup work that happens after the display is ready.

The Basic Syntax

Every useEffect follows the same pattern:

useEffect(() => {
  // Your side effect code goes here
  // This runs AFTER React renders the component

  return () => {
    // Optional: cleanup code goes here
    // This runs before the effect re-runs, or when the component unmounts
  };
}, [dependency1, dependency2]); // Optional: when to re-run

Three parts: the effect function, the optional cleanup function, and the optional dependency array. That's it. But those three parts combine into patterns that confuse even experienced developers — so let's break down each one.

The Three useEffect Patterns

The dependency array — that second argument — completely changes when your effect runs. There are exactly three patterns, and AI uses all three. Knowing which is which is the key to understanding any useEffect you encounter.

Pattern 1: Run After Every Render (No Dependency Array)

useEffect(() => {
  console.log('Component rendered! Count is:', count);
});  // ← No second argument at all

When there's no dependency array, the effect runs after every single render. Component mounts? Effect runs. State changes? Effect runs. Parent re-renders? Effect runs. This is the least common pattern because it's the most expensive — there are very few cases where you need code to run on literally every render.

⚠️ Rare in Practice

If AI generates a useEffect with no dependency array, it's usually a mistake. Ask: "Should this effect run on every single render, or only when specific values change?" Nine times out of ten, it needs a dependency array.

Real-world use case: Logging or analytics that need to track every render, or syncing with an external system that should always match the current state.

Pattern 2: Run Once on Mount (Empty Dependency Array)

useEffect(() => {
  // This runs ONCE — when the component first appears on screen
  fetch('https://api.example.com/user/profile')
    .then(res => res.json())
    .then(data => setUser(data));
}, []);  // ← Empty array = "no dependencies" = run once

The empty array [] tells React: "This effect has no dependencies. Nothing can change that would require it to run again." So React runs it once, after the first render, and never again.

This is the most common pattern you'll see in AI-generated code. It's the React equivalent of "when the page loads, do this." Use it for:

  • Fetching initial data from an API
  • Setting up event listeners that last the lifetime of the component
  • Initializing third-party libraries (charts, maps, editors)
  • Checking authentication status

Here's a complete, realistic example — a component that fetches user data when it mounts:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('Failed to fetch');
        return res.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);  // Runs once on mount

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <h1>{user.name}</h1>;
}
⚠️ Spot the Bug

This component takes userId as a prop but doesn't include it in the dependency array. If the parent passes a different userId, the effect won't re-run — it'll still show the old user's data. This is the #1 useEffect bug AI generates. The fix: [userId] in the dependency array. That's Pattern 3.

Pattern 3: Run When Specific Values Change

useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => setUser(data));
}, [userId]);  // ← Re-runs whenever userId changes

This is the most powerful and most nuanced pattern. The dependency array lists the specific values that should trigger the effect. React compares these values between renders — if any of them changed, the effect runs again. If none changed, React skips it.

Here's a more complete example — a search component that fetches results whenever the search query changes:

function SearchResults({ query, category }) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;  // Don't fetch if there's no query
    }

    setLoading(true);
    fetch(`/api/search?q=${encodeURIComponent(query)}&cat=${category}`)
      .then(res => res.json())
      .then(data => {
        setResults(data.items);
        setLoading(false);
      });
  }, [query, category]);  // Re-runs when either query OR category changes

  return (
    <div>
      {loading && <p>Searching...</p>}
      {results.map(item => (
        <div key={item.id}>{item.title}</div>
      ))}
    </div>
  );
}

Every time query or category changes, the effect fires a new search. When neither changes (like when the user opens a sidebar), React skips the effect entirely. This is exactly the behavior you want.

Another common real-world example — updating the document title:

useEffect(() => {
  document.title = `${unreadCount} new messages — MyApp`;
}, [unreadCount]);  // Updates the browser tab whenever unread count changes

The Cleanup Function: Preventing Memory Leaks

This is the part that trips up most vibe coders — and most AI tools. When your effect sets something up (a timer, an event listener, a subscription), you need to tear it down when the component leaves the screen. Otherwise, that timer keeps ticking, that listener keeps listening, and your app slowly leaks memory.

The cleanup function is the function you return from your effect:

useEffect(() => {
  // SETUP: Add an event listener
  const handleResize = () => {
    setWindowWidth(window.innerWidth);
  };
  window.addEventListener('resize', handleResize);

  // CLEANUP: Remove the event listener
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);  // Setup once, cleanup when component unmounts

React calls the cleanup function in two situations:

  1. When the component unmounts (leaves the screen) — this prevents memory leaks
  2. Before the effect re-runs (when dependencies change) — this prevents stacking up duplicate listeners or timers

Here's a timer example that shows why cleanup matters:

// ❌ WITHOUT CLEANUP — memory leak!
useEffect(() => {
  setInterval(() => {
    setSeconds(s => s + 1);
  }, 1000);
  // Timer keeps running even after component unmounts!
}, []);

// ✅ WITH CLEANUP — proper
useEffect(() => {
  const timer = setInterval(() => {
    setSeconds(s => s + 1);
  }, 1000);

  return () => clearInterval(timer);  // Stop the timer on unmount
}, []);

And a WebSocket subscription example — the kind of thing AI generates for real-time features:

useEffect(() => {
  const ws = new WebSocket('wss://api.example.com/live');

  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    setMessages(prev => [...prev, data]);
  };

  ws.onerror = (error) => {
    console.error('WebSocket error:', error);
  };

  // Cleanup: close the connection when done
  return () => {
    ws.close();
  };
}, []);
💡 The Rule of Thumb

If your effect calls addEventListener, setInterval, setTimeout, subscribe, or opens any kind of connection — it needs a cleanup function that calls the corresponding removeEventListener, clearInterval, clearTimeout, unsubscribe, or close. Every setup needs a teardown.

The Most Common useEffect Bugs

These are the bugs you'll encounter most often in AI-generated code. Learn to spot them and you'll save hours of debugging.

Bug #1: The Infinite Loop

This is the most common useEffect bug, period. It happens when your effect updates a value that's in its own dependency array:

// 🔴 INFINITE LOOP — DO NOT DO THIS
useEffect(() => {
  setCount(count + 1);  // Updates count
}, [count]);            // Runs when count changes
// count changes → effect runs → updates count → effect runs → forever

A more subtle version that AI generates regularly:

// 🔴 INFINITE LOOP — harder to spot
useEffect(() => {
  const filtered = items.filter(i => i.active);
  setFilteredItems(filtered);  // New array every time!
}, [items]);

// If items is created fresh every render (common with AI code):
// items changes → effect runs → sets state → re-render → items is new again → loop

The fix: Make sure your effect doesn't trigger its own dependencies. Use the functional form of setState (setCount(prev => prev + 1)) when updating based on previous state, and make sure objects/arrays in the dependency array are stable references.

Bug #2: Stale Closures

This one is sneaky. Your effect "remembers" old values because JavaScript closures capture variables at the time the effect was created:

// 🔴 STALE CLOSURE — count is always 0 inside the interval
useEffect(() => {
  const timer = setInterval(() => {
    console.log('Count is:', count);  // Always logs the initial value!
    setCount(count + 1);              // Always sets to 1!
  }, 1000);
  return () => clearInterval(timer);
}, []);  // Empty deps = effect never re-runs, never gets fresh count

// ✅ FIX: Use the functional form of setState
useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1);  // Always uses the current value
  }, 1000);
  return () => clearInterval(timer);
}, []);

The empty dependency array means the effect captures count as it was on mount (0) and never updates. The functional form prev => prev + 1 always works with the latest value.

Bug #3: Race Conditions on Fetch

This happens when a user navigates quickly and multiple fetch requests overlap:

// 🔴 RACE CONDITION — user clicks user 1, then user 2 quickly
// If user 1's response arrives AFTER user 2's, the UI shows user 1's data
useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => setUser(data));  // Might overwrite newer data!
}, [userId]);

// ✅ FIX: Use a cleanup flag to ignore stale responses
useEffect(() => {
  let cancelled = false;

  fetch(`/api/users/${userId}`)
    .then(res => res.json())
    .then(data => {
      if (!cancelled) {  // Only update if this effect is still current
        setUser(data);
      }
    });

  return () => {
    cancelled = true;  // Mark this effect as stale
  };
}, [userId]);

The cleanup function sets cancelled = true when the effect re-runs with a new userId. The old fetch's .then still fires, but it sees cancelled is true and skips the state update. This is a pattern AI should generate but often doesn't.

Bug #4: Missing Dependencies

The dependency array tells React when to re-run the effect. If you use a value inside the effect but don't list it in the array, the effect runs with stale data:

// 🔴 MISSING DEPENDENCY — apiKey could change but effect won't re-run
useEffect(() => {
  fetch(`/api/data?key=${apiKey}`)  // Uses apiKey
    .then(res => res.json())
    .then(data => setData(data));
}, []);  // apiKey not listed! Effect won't re-run when it changes

// ✅ FIX: Include all values the effect uses
useEffect(() => {
  fetch(`/api/data?key=${apiKey}`)
    .then(res => res.json())
    .then(data => setData(data));
}, [apiKey]);  // Now the effect re-runs when apiKey changes
💡 Install the Lint Rule

The eslint-plugin-react-hooks package includes an exhaustive-deps rule that automatically detects missing dependencies. If your project doesn't have it, tell your AI: "Add eslint-plugin-react-hooks to the project and enable the exhaustive-deps rule." This catches dependency bugs before they reach production.

What AI Gets Wrong About useEffect

⚠️ AI Failure Mode #1: Missing Cleanup Functions

AI sets up event listeners, timers, and subscriptions inside useEffect but frequently forgets to return a cleanup function. The code works — until the user navigates away and back, and now you have two listeners, two timers, two subscriptions running simultaneously. Fix: Ask AI "Does this useEffect need a cleanup function? It sets up [listener/timer/subscription]." AI will almost always add one.

⚠️ AI Failure Mode #2: Using useEffect for Things That Don't Need It

AI puts calculations and data transformations inside useEffect when they should just be regular code in the component body or useMemo. If you're filtering a list based on state, you don't need useEffect — just filter directly during render. useEffect is for side effects (reaching outside the component), not for deriving state from other state. Fix: "This useEffect just transforms data — can we compute it directly during render instead?"

⚠️ AI Failure Mode #3: No Race Condition Handling

When AI generates useEffect with fetch calls, it almost never includes a cancellation flag or AbortController. This means fast navigation can cause stale data to overwrite fresh data. Fix: "Add race condition handling to this useEffect — use a cancelled flag in the cleanup function."

⚠️ AI Failure Mode #4: Making useEffect async

AI sometimes writes useEffect(async () => { ... }) — making the effect function itself async. This is wrong because useEffect expects the return value to be a cleanup function, not a Promise. React will warn about this. Fix: Define the async function inside the effect, then call it: useEffect(() => { async function load() { ... } load(); }, []).

⚠️ AI Failure Mode #5: Dependency Array Object Traps

AI includes objects or arrays in dependency arrays without realizing React compares them by reference, not by content. { name: "Chuck" } in one render and { name: "Chuck" } in the next render are different objects to React, even though they contain the same data. This causes the effect to run on every render. Fix: Depend on primitive values (strings, numbers, booleans) when possible, or use useMemo to stabilize object references.

The useEffect Mental Model: Synchronization, Not Lifecycle

Here's the shift that separates beginners from people who actually understand useEffect: it's not about "when the component mounts" or "when the component unmounts." It's about synchronization.

useEffect synchronizes your component with something external. Think of it like this:

  • "Keep the document title in sync with unreadCount"
  • "Keep the chat connection in sync with roomId"
  • "Keep the displayed data in sync with userId"

Each useEffect says: "Whenever these values change, sync up with the outside world." The cleanup function undoes the previous sync. The dependency array controls what triggers a re-sync.

This mental model helps you decide when to use useEffect and when not to. Ask: "Am I synchronizing with something outside my component?" If yes, useEffect. If no, you probably don't need it.

Debugging useEffect with AI

💬 useEffect Debug Prompt

"This useEffect runs in an infinite loop. Here's the component: [paste]. Find the dependency that's causing the loop and fix it without changing the feature behavior."

💬 Cleanup Audit Prompt

"Review all useEffect hooks in this component. Which ones are missing cleanup functions? Add cleanup for any that set up listeners, timers, or subscriptions."

AI is actually quite good at debugging useEffect issues when you give it specific symptoms. "My component re-renders infinitely" or "Data shows the wrong user after navigating" are clear enough for AI to pinpoint the useEffect bug. Vague prompts like "my component is broken" will get vague answers.

💡 Pro Tip: The Console.log Test

If you're not sure when your effect runs, add console.log('Effect running', { dep1, dep2 }) as the first line. Open the browser console and watch. If it logs continuously, you have an infinite loop. If it doesn't log when you expect, check your dependency array. This simple technique solves 80% of useEffect mysteries.

Frequently Asked Questions

useState holds data inside your component (like a counter value or form input). useEffect runs code that reaches outside your component (like fetching data from an API or setting up a timer). They often work together: useEffect fetches data, then useState stores it. Think of useState as memory and useEffect as action.

React 18+ runs effects twice in development mode (Strict Mode) on purpose. It mounts your component, runs the effect, unmounts it, then mounts it again. This helps catch bugs where your cleanup function is missing or broken. It only happens in development — in production, effects run once as expected. Don't remove Strict Mode to "fix" this; fix your cleanup function instead.

An infinite loop happens when your useEffect updates a value that's in its own dependency array. The fix: make sure your dependency array only includes values that should actually trigger the effect. Use the functional form of setState (prev => prev + 1) instead of referencing state directly. If objects in your dependencies change reference every render, stabilize them with useMemo.

Not directly — useEffect expects you to return either nothing or a cleanup function, but async functions always return a Promise. The solution: define an async function inside the effect, then call it immediately. Write: useEffect(() => { async function fetchData() { const res = await fetch(url); } fetchData(); }, [url]).

It works, but modern React recommends better alternatives. Libraries like TanStack Query (React Query) and SWR handle caching, loading states, error handling, and race conditions automatically. Framework-level solutions like Next.js Server Components and Remix loaders are even better. useEffect for fetching is fine for learning and simple cases, but for production apps, a data-fetching library saves you from reinventing the wheel.