AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

useEffect Actually Works: Internals & Execution Model (2025)

Last updated:
useEffect: Internals and Execution Model for React 19

Master useEffect's real mechanics in React 19. Learn cleanup sequences, fiber architecture, dependency tracking, and memory management with production code examples.

If you've been writing React for more than a few weeks, you've used useEffect. But do you really understand what happens when that hook runs? Most developers can guess "it runs after render" and know about cleanup functions—yet they still end up with infinite loops, race conditions, and mysterious state bugs. The gap between "knowing useEffect" and "understanding useEffect" is where real production problems hide.

After React 19, the mental model hasn't changed fundamentally, but the execution details matter more than ever. In this guide, we're going deeper than the surface level. We'll trace through React's fiber architecture, see exactly when cleanup functions execute, understand how dependency comparisons really work, and explore the edge cases that catch most developers off guard.

This isn't "how to use useEffect"—there are dozens of tutorials for that. This is about the mechanics underneath, the internals that determine whether your effects run one time or a thousand times, and why seemingly identical code behaves differently depending on how your components are structured.

# Table of Contents

  1. What useEffect Really Does
  2. The Execution Timeline
  3. Dependency Array: How React Actually Tracks Changes
  4. Cleanup Functions and Fiber Updates
  5. The Race Condition Problem
  6. Memory Leaks and Reference Stability
  7. Practical Application: Real-World Scenarios
  8. FAQ

# What useEffect Really Does

Before diving into execution details, let's clarify what useEffect actually is. It's not some magical function that runs "after render." It's a scheduler. React registers your effect as a side effect that needs to be executed during a specific phase of the component lifecycle.

When you write:

typescript
useEffect(() => {
  console.log('Effect running');
}, [dependency]);

You're telling React: "After this component has been committed to the DOM, run this function. If the dependency changes on the next render, run it again. If I return a function, use that as cleanup."

The key word here is committed. React doesn't run effects during render. It can't. Rendering must be pure—no side effects allowed. Effects run after React has updated the DOM, during what's called the commit phase.

This separation matters because it means your effect runs on a component that's already been painted to the screen. That's why you can measure DOM elements in effects but not during render. It also explains why you can't "cancel" an effect mid-way through execution—once React commits, the effect is queued up and will run.


# The Execution Timeline

Understanding when effects run relative to renders is crucial. Let's trace the actual sequence:

  1. Render Phase (pure, no side effects)

    • React calls your component function
    • Component returns JSX
    • React creates a fiber tree
    • Dependencies are captured at this point
  2. Paint to DOM (browser updates)

    • React commits changes to the actual DOM
    • Browser paints new pixels
    • Layout and reflow happen
  3. Effect Execution Phase (your code runs here)

    • Effects are scheduled and executed
    • Cleanup functions from previous render run first
    • New effects run after cleanup

Here's what actually happens with multiple effects:

typescript
function Component() {
  console.log('1. Render phase');
  
  useEffect(() => {
    console.log('3. Effect A (first in, first out)');
    return () => console.log('4. Cleanup A');
  }, []);
  
  useEffect(() => {
    console.log('5. Effect B');
    return () => console.log('6. Cleanup B');
  }, []);
  
  return <div>Component</div>;
}

Output on first mount:

typescript
1. Render phase
2. DOM updated (browser paints)
3. Effect A (first in, first out)
5. Effect B

On unmount:

typescript
6. Cleanup B
4. Cleanup A

Notice: cleanup functions run in reverse order. This is intentional. If Effect A depends on something Effect B sets up, we need to clean up A first.


# Dependency Array: How React Actually Tracks Changes

The dependency array is where most confusion lives. React doesn't do anything magical with it—no deep equality checks, no fancy algorithms. It does the simplest possible thing: Object.is() comparison.

When you write:

typescript
const [count, setCount] = useState(0);
const data = { id: 1 };

useEffect(() => {
  console.log('Effect ran');
}, [count, data]);

React compares each element in this render's dependency array with the previous render's using Object.is():

javascript
// Pseudocode of what React actually does
const prevDeps = [0, { id: 1 }];  // previous render
const nextDeps = [0, { id: 1 }];  // current render

let hasChanged = false;
for (let i = 0; i < nextDeps.length; i++) {
  if (!Object.is(prevDeps[i], nextDeps[i])) {
    hasChanged = true;
    break;
  }
}

if (hasChanged || prevDeps === undefined) {
  // Run effect
}

This means:

typescript
const obj1 = { id: 1 };
const obj2 = { id: 1 };

Object.is(obj1, obj2); // false! Different object references
Object.is(obj1, obj1); // true! Same reference

This is why this code creates an infinite loop:

typescript
function BadExample() {
  useEffect(() => {
    console.log('Running');
  }, [{ id: 1 }]); // ❌ New object on every render!
}

// On each render:
// 1. New object { id: 1 } is created
// 2. Object.is(oldObj, newObj) is false
// 3. Effect runs
// 4. Component re-renders
// 5. Back to step 1

And why this works:

typescript
function GoodExample() {
  const data = useMemo(() => ({ id: 1 }), []); // Stable reference
  
  useEffect(() => {
    console.log('Running once');
  }, [data]);
}

// Object reference is stable, effect runs once

The Missing Piece: React doesn't validate your dependency array at runtime. If you omit a dependency, React won't know. If you include unnecessary dependencies, effects just run more often than needed. ESLint's exhaustive-deps rule exists because React itself can't catch this mistake.


# Cleanup Functions and Fiber Updates

Cleanup functions are where the fiber architecture becomes visible. Each component instance gets a fiber node that tracks:

  • The component's state
  • Effects associated with this component
  • Cleanup functions that need to run
  • The current and previous version of dependencies

When React detects a dependency change (or a component unmounts), it doesn't just call your cleanup function randomly. Here's the actual sequence:

typescript
interface FiberNode {
  state: any;
  hooks: Hook[];
  updateQueue: Effect[];
}

interface Effect {
  tag: 'Insertion' | 'Mutation' | 'Layout';
  create: () => (() => void) | void;
  destroy: (() => void) | null;
  deps: any[];
  next: Effect | null;
}

When your component updates:

  1. Cleanup Scheduling: React iterates through the component's effect list
  2. Dependency Check: For each effect, React compares the current dependency array with the previous one
  3. Cleanup Execution: If dependencies changed, the cleanup function (destroy) from the previous render is marked for execution
  4. New Effect Scheduled: The new effect (created from the new render) is queued

The critical detail: cleanup functions and new effects are separate operations. React schedules all cleanups to run in one batch, then all new effects in another batch.

typescript
function DataFetcher({ userId }) {
  useEffect(() => {
    const controller = new AbortController();
    
    fetch(`/user/${userId}`, { signal: controller.signal })
      .then(r => r.json())
      .then(data => console.log('Data:', data));
    
    return () => {
      console.log('Cleanup: Aborting fetch for user', userId);
      controller.abort();
    };
  }, [userId]);

  return <div>User data</div>;
}

// When userId changes from 1 to 2:
// 1. Component renders with userId=2
// 2. React marks cleanup (from userId=1 effect) for execution
// 3. React marks new effect (with userId=2) for execution
// 4. Cleanup runs: aborts userId=1 fetch
// 5. New effect runs: starts userId=2 fetch

This ordering is not accidental. It ensures that old effects are cleaned up before new ones initialize, preventing resource conflicts.


# The Race Condition Problem

Here's where understanding the timeline becomes a production issue. Consider this common pattern:

typescript
const [data, setData] = useState(null);

useEffect(() => {
  const fetchData = async () => {
    const response = await fetch(`/api/data/${id}`);
    const json = await response.json();
    setData(json); // Dangerous!
  };
  
  fetchData();
}, [id]);

On the surface, this looks correct. When id changes, fetch new data. But what if:

  1. User loads component with id=1
  2. Effect starts fetch for /api/data/1
  3. Before response arrives, user navigates away
  4. Component unmounts, then remounts with id=2
  5. Cleanup doesn't cancel the original fetch
  6. /api/data/1 response arrives after /api/data/2
  7. State updates with old data

React's fiber architecture doesn't protect you here. The cleanup function runs, but it doesn't automatically cancel the fetch that was already in flight.

The solution demonstrates why understanding the execution model matters:

typescript
const [data, setData] = useState(null);

useEffect(() => {
  let isMounted = true; // Flag to track component lifecycle
  
  const fetchData = async () => {
    const response = await fetch(`/api/data/${id}`);
    const json = await response.json();
    
    if (isMounted) {
      setData(json);
    }
  };
  
  fetchData();
  
  return () => {
    isMounted = false; // Prevent state updates after unmount
  };
}, [id]);

Or, better yet, use AbortController:

typescript
useEffect(() => {
  const controller = new AbortController();
  
  const fetchData = async () => {
    try {
      const response = await fetch(`/api/data/${id}`, {
        signal: controller.signal
      });
      const json = await response.json();
      setData(json);
    } catch (e) {
      if (e.name !== 'AbortError') {
        console.error(e);
      }
    }
  };
  
  fetchData();
  
  return () => controller.abort();
}, [id]);

The second approach leverages the browser's native cancellation mechanism. When the cleanup function runs, it aborts the fetch immediately, preventing the response from updating state.


# Memory Leaks and Reference Stability

Memory leaks in React components almost always trace back to effects that set up listeners without proper cleanup. But the real issue is usually reference stability.

typescript
function ChatComponent({ userId }) {
  const messageHandler = (message) => {
    console.log(`${userId}: ${message}`);
  };
  
  useEffect(() => {
    socket.on('message', messageHandler);
    
    return () => {
      socket.off('message', messageHandler);
    };
  }, [userId]); // ❌ Passing userId without messageHandler
}

This creates a subtle leak: messageHandler references userId, but messageHandler is not in the dependency array. When userId changes:

  1. New messageHandler function is created on each render
  2. Object.is(oldMessageHandler, newMessageHandler) is always false
  3. Effect runs, registering a new listener with the new function
  4. Cleanup tries to unregister the old listener... but using the new function reference
  5. The old listener remains registered

The socket now has multiple message handlers stacked up, all processing messages.

The fix is to include functions in the dependency array or use useCallback:

typescript
const messageHandler = useCallback((message) => {
  console.log(`${userId}: ${message}`);
}, [userId]);

useEffect(() => {
  socket.on('message', messageHandler);
  
  return () => {
    socket.off('message', messageHandler);
  };
}, [messageHandler]); // ✅ Now messageHandler is stable unless userId changes

Or use the function reference approach:

typescript
useEffect(() => {
  const handler = (message) => {
    console.log(`${userId}: ${message}`);
  };
  
  socket.on('message', handler);
  
  return () => {
    socket.off('message', handler);
  };
}, [userId]); // ✅ Same reference is cleaned up

The fiber architecture doesn't check reference types for you. It compares using Object.is() and moves on. If your cleanup function can't find what to clean up because the reference changed, the cleanup silently fails.


# Practical Application: Real-World Scenarios

# Scenario: Building a Resilient Data Fetching Hook

Let's build a production-grade data fetching pattern that handles the complexities we've discussed:

typescript
interface UseFetchOptions<T> {
  onError?: (error: Error) => void;
  onSuccess?: (data: T) => void;
  refetchInterval?: number;
}

interface UseFetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

function useFetch<T>(
  url: string,
  options?: UseFetchOptions<T>
): UseFetchState<T> {
  const [state, setState] = useState<UseFetchState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    let isMounted = true;
    let timeout: NodeJS.Timeout;

    const performFetch = async () => {
      try {
        // Clear previous error on new fetch
        setState(prev => ({ ...prev, loading: true, error: null }));

        const controller = new AbortController();
        const response = await fetch(url, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const data = await response.json() as T;

        // Only update state if component is still mounted
        if (isMounted) {
          setState({ data, loading: false, error: null });
          options?.onSuccess?.(data);
        }
      } catch (error) {
        if (isMounted && error instanceof Error) {
          if (error.name !== 'AbortError') {
            setState(prev => ({
              ...prev,
              error,
              loading: false,
            }));
            options?.onError?.(error);
          }
        }
      }
    };

    performFetch();

    // Optional: refetch at intervals
    if (options?.refetchInterval) {
      timeout = setInterval(performFetch, options.refetchInterval);
    }

    return () => {
      isMounted = false;
      if (timeout) clearInterval(timeout);
    };
  }, [url, options?.refetchInterval]);

  return state;
}

// Usage
function UserProfile({ userId }: { userId: string }) {
  const { data, loading, error } = useFetch<{ name: string; email: string }>(
    `/api/users/${userId}`,
    {
      onSuccess: (data) => {
        console.log('User loaded:', data.name);
      },
      onError: (error) => {
        console.error('Failed to load user:', error.message);
      },
    }
  );

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

  return (
    <div>
      <h1>{data?.name}</h1>
      <p>{data?.email}</p>
    </div>
  );
}

JavaScript Version:

javascript
function useFetch(url, options) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    let isMounted = true;
    let timeout;

    const performFetch = async () => {
      try {
        setState(prev => ({ ...prev, loading: true, error: null }));

        const controller = new AbortController();
        const response = await fetch(url, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const data = await response.json();

        if (isMounted) {
          setState({ data, loading: false, error: null });
          options?.onSuccess?.(data);
        }
      } catch (error) {
        if (isMounted && error?.name !== 'AbortError') {
          setState(prev => ({
            ...prev,
            error,
            loading: false,
          }));
          options?.onError?.(error);
        }
      }
    };

    performFetch();

    if (options?.refetchInterval) {
      timeout = setInterval(performFetch, options.refetchInterval);
    }

    return () => {
      isMounted = false;
      if (timeout) clearInterval(timeout);
    };
  }, [url, options?.refetchInterval]);

  return state;
}

# Scenario: Building a Form with Validation and Auto-Save

This example demonstrates how understanding effect cleanup timing prevents data loss:

typescript
interface FormData {
  title: string;
  content: string;
}

interface UseAutoSaveOptions {
  delay?: number;
  onSave?: (data: FormData) => Promise<void>;
}

function useAutoSave(
  data: FormData,
  options: UseAutoSaveOptions = {}
) {
  const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
  const delayMs = options.delay ?? 1000;

  useEffect(() => {
    let timeoutId: NodeJS.Timeout;

    const save = async () => {
      try {
        setSaveStatus('saving');
        await options.onSave?.(data);
        setSaveStatus('idle');
      } catch (error) {
        setSaveStatus('error');
        console.error('Auto-save failed:', error);
      }
    };

    // Debounce: only save if user hasn't typed for `delay` ms
    timeoutId = setTimeout(save, delayMs);

    // Cleanup: cancel pending save if data changes again
    return () => clearTimeout(timeoutId);
  }, [data, delayMs, options]);

  return saveStatus;
}

function EditDocument() {
  const [form, setForm] = useState<FormData>({ title: '', content: '' });
  const saveStatus = useAutoSave(form, {
    delay: 1500,
    onSave: async (data) => {
      // Simulate API call
      await new Promise(resolve => setTimeout(resolve, 500));
      console.log('Saved:', data);
    },
  });

  return (
    <div>
      <input
        value={form.title}
        onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))}
        placeholder="Title"
      />
      <textarea
        value={form.content}
        onChange={(e) => setForm(prev => ({ ...prev, content: e.target.value }))}
        placeholder="Content"
      />
      <div>
        {saveStatus === 'saving' && <span>Saving...</span>}
        {saveStatus === 'error' && <span style={{ color: 'red' }}>Save failed</span>}
      </div>
    </div>
  );
}

JavaScript Version:

javascript
function useAutoSave(data, options = {}) {
  const [saveStatus, setSaveStatus] = useState('idle');
  const delayMs = options.delay ?? 1000;

  useEffect(() => {
    let timeoutId;

    const save = async () => {
      try {
        setSaveStatus('saving');
        await options.onSave?.(data);
        setSaveStatus('idle');
      } catch (error) {
        setSaveStatus('error');
        console.error('Auto-save failed:', error);
      }
    };

    timeoutId = setTimeout(save, delayMs);

    return () => clearTimeout(timeoutId);
  }, [data, delayMs, options]);

  return saveStatus;
}

Performance Note: This pattern prevents unnecessary saves by leveraging cleanup function timing. Each keystroke clears the pending save (cleanup runs), and a new save is scheduled. Only after the user stops typing for 1.5 seconds does the actual save execute.


# FAQ

Q: Why does my effect run twice on mount in development?

A: React 18+ intentionally double-invokes effects in development when StrictMode is enabled. This helps catch bugs where effects aren't idempotent. Each component mounts, then immediately unmounts and remounts. It's a feature, not a bug. Your cleanup function should handle being called multiple times.

typescript
useEffect(() => {
  console.log('Mounted');
  return () => console.log('Cleanup called'); // Will be called even on mount in dev
}, []);

Disable StrictMode in production, but leave it in development.

Q: Can I make effects run on every render without a dependency array?

A: Yes, but you probably shouldn't:

typescript
useEffect(() => {
  console.log('Runs every render');
}); // No dependency array

This runs after every render, including when props or state change. It's useful for measuring DOM or syncing external state, but it's a performance risk if you're not careful. It's almost never the right answer.

Q: What's the difference between Object.is() and === for dependency tracking?

A: For primitives, they're identical. For objects and functions, Object.is() strictly compares references:

typescript
Object.is(5, 5) === (5 === 5) // true
Object.is({}, {}) === ({} === {}) // true (both false)
Object.is(NaN, NaN) // true (different from ===)
Object.is(0, -0) // false (different from ===)

For useEffect dependencies, you'll mainly hit the object/function differences.

Q: Can I update state inside cleanup functions?

A: Technically yes, but it's a red flag:

typescript
useEffect(() => {
  return () => {
    setState(value); // ❌ Cleanup is called after unmount
  };
}, []);

When cleanup runs during unmount, the component is no longer mounted. Setting state is ignored, and React will warn you. If the effect runs before unmount (dependency change), the state update happens, but it's confusing because cleanup usually means "stop doing something," not "do something else."

Q: Does useEffect work with custom hooks?

A: Yes, hooks can call hooks:

typescript
function useCustomEffect() {
  useEffect(() => {
    console.log('Inside custom hook');
  }, []);
}

As long as you follow the Rules of Hooks (no conditional calls, only call at top level), it works. The effect still belongs to the component that calls the custom hook.

Q: What happens if I use async directly in useEffect?

A: You can't:

typescript
useEffect(async () => { // ❌ TypeScript error
  const data = await fetch('/data');
}, []);

useEffect's callback must return either void or a cleanup function. An async function returns a Promise. Instead, create an async function inside:

typescript
useEffect(() => {
  const fetchData = async () => {
    const data = await fetch('/data');
  };
  
  fetchData();
}, []);

This is a safety feature—it forces you to handle errors and cleanup explicitly.



Questions? The comments section below is the best place to discuss edge cases, gotchas, and production patterns. What confuses you most about useEffect?

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT