Reactintermediate

useEffect Hook Deep Dive

Master React's useEffect hook: side effects, dependency arrays, cleanup functions, race conditions, AbortController, event listeners, and performance patterns. The complete guide.

19 min readยทPublished Mar 15, 2026
reacthooksuseeffectside-effects

What Is useEffect?

useEffect is React's hook for performing side effects in function components. A side effect is anything that interacts with the outside world โ€” fetching data, subscribing to events, manipulating the DOM directly, setting timers, or writing to localStorage.

import { useState, useEffect } from 'react';

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

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

  if (!user) return <p>Loading...</p>;
  return <h1>{user.name}</h1>;
}

The key idea: rendering should be pure. Your component function should only calculate JSX โ€” no side effects during render. useEffect lets you say "run this code after the render is committed to the DOM."

Effect vs Render

Understanding when code runs is fundamental to using useEffect correctly.

Component function called
        |
        v
  Render phase (pure)
  - Calculate JSX
  - No side effects
  - May be called multiple times (Strict Mode)
        |
        v
  Commit phase
  - React updates the DOM
        |
        v
  Effects phase
  - useEffect callbacks run
  - Cleanup from previous effects runs first
  - Browser has painted
function EffectTiming() {
  console.log('1. Render: component function body');

  useEffect(() => {
    console.log('3. Effect: runs AFTER DOM update and paint');
    return () => {
      console.log('2. Cleanup: runs BEFORE next effect (or unmount)');
    };
  });

  return <div>Check console</div>;
}

// First render output:
// 1. Render: component function body
// 3. Effect: runs AFTER DOM update and paint

// On re-render:
// 1. Render: component function body
// 2. Cleanup: runs BEFORE next effect
// 3. Effect: runs AFTER DOM update and paint

useEffect vs useLayoutEffect

useEffectuseLayoutEffect
When it runsAfter paintBefore paint
Blocks painting?NoYes
Use forData fetching, subscriptions, loggingDOM measurements, scroll position, visual updates
PerformanceBetter (non-blocking)Worse if overused (blocks visual update)
// useLayoutEffect โ€” runs synchronously after DOM mutation, before paint
// Use when you need to measure or modify DOM before user sees it
import { useLayoutEffect, useRef, useState } from 'react';

function Tooltip({ text, targetRef }) {
  const tooltipRef = useRef(null);
  const [position, setPosition] = useState({ top: 0, left: 0 });

  useLayoutEffect(() => {
    // Measure DOM before paint to avoid flicker
    const targetRect = targetRef.current.getBoundingClientRect();
    const tooltipRect = tooltipRef.current.getBoundingClientRect();
    setPosition({
      top: targetRect.top - tooltipRect.height - 8,
      left: targetRect.left + (targetRect.width - tooltipRect.width) / 2,
    });
  }, [targetRef]);

  return (
    <div
      ref={tooltipRef}
      style={{ position: 'fixed', top: position.top, left: position.left }}
    >
      {text}
    </div>
  );
}

The Dependency Array

The dependency array is the second argument to useEffect. It controls when the effect runs.

No Dependency Array โ€” Runs After Every Render

useEffect(() => {
  console.log('Runs after every render');
});

Empty Array โ€” Runs Only on Mount

useEffect(() => {
  console.log('Runs once, after first render');
}, []);

With Dependencies โ€” Runs When Dependencies Change

useEffect(() => {
  console.log('Runs when userId or role changes');
  fetchUserData(userId, role);
}, [userId, role]);

Dependency Array Comparison

Render 1: useEffect(fn, [1, 'admin'])
  -> Effect runs (first render)

Render 2: useEffect(fn, [1, 'admin'])
  -> Effect SKIPPED (deps unchanged: 1===1, 'admin'==='admin')

Render 3: useEffect(fn, [2, 'admin'])
  -> Effect RUNS (deps changed: 1!==2)

Render 4: useEffect(fn, [2, 'user'])
  -> Effect RUNS (deps changed: 'admin'!=='user')

React uses Object.is to compare each dependency with its previous value. For primitives this is value comparison. For objects and arrays, this is reference comparison.

Reference Comparison Gotcha

function SearchResults({ query }) {
  // BUG: new object created every render, effect runs every time
  const options = { limit: 10, sort: 'date' };

  useEffect(() => {
    fetchResults(query, options);
  }, [query, options]);  // options is a new object every render!
}

// Fix 1: Move object outside component (if it's static)
const OPTIONS = { limit: 10, sort: 'date' };

function SearchResults({ query }) {
  useEffect(() => {
    fetchResults(query, OPTIONS);
  }, [query]);  // OPTIONS is the same reference
}

// Fix 2: useMemo for dynamic objects
function SearchResults({ query, sortOrder }) {
  const options = useMemo(() => ({ limit: 10, sort: sortOrder }), [sortOrder]);

  useEffect(() => {
    fetchResults(query, options);
  }, [query, options]);  // options only changes when sortOrder changes
}

// Fix 3: Use the primitive values directly
function SearchResults({ query, sortOrder }) {
  useEffect(() => {
    fetchResults(query, { limit: 10, sort: sortOrder });
  }, [query, sortOrder]);  // primitives in deps
}

Missing Dependencies

React's react-hooks/exhaustive-deps ESLint rule will warn you about missing dependencies. Don't ignore these warnings. Missing dependencies cause stale closures โ€” your effect reads old values.

The Problem

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('message', (msg) => {
      // BUG: messages is stale (captured from initial render)
      setMessages([...messages, msg]);
    });
    connection.connect();

    return () => connection.disconnect();
  }, [roomId]);
  // ESLint warns: 'messages' is missing from dependency array
}

The Fix: Functional Updates

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('message', (msg) => {
      // CORRECT: functional update doesn't need messages in deps
      setMessages(prev => [...prev, msg]);
    });
    connection.connect();

    return () => connection.disconnect();
  }, [roomId]);  // No lint warning โ€” messages not referenced
}

When a Dependency Changes Too Often

Sometimes a dependency changes on every render and you don't want the effect to re-run.

// Problem: onMessage prop changes every render (parent creates new function)
function ChatRoom({ roomId, onMessage }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('message', onMessage);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, onMessage]);  // Reconnects every render if onMessage is unstable
}

// Fix: Use useEffectEvent (React 19+) or ref pattern
function ChatRoom({ roomId, onMessage }) {
  const onMessageRef = useRef(onMessage);
  onMessageRef.current = onMessage;  // Always points to latest

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on('message', (msg) => {
      onMessageRef.current(msg);  // Reads latest without being a dependency
    });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);  // Only reconnects when roomId changes
}

Cleanup Functions

The function returned from useEffect is the cleanup function. It runs:

  1. Before the effect re-runs (when dependencies change)
  2. When the component unmounts

When Cleanup Runs

Mount:
  Effect runs          -> return cleanup_v1

Update (deps changed):
  cleanup_v1 runs      -> (cleans up previous effect)
  Effect runs          -> return cleanup_v2

Update (deps changed again):
  cleanup_v2 runs      -> (cleans up previous effect)
  Effect runs          -> return cleanup_v3

Unmount:
  cleanup_v3 runs      -> (final cleanup)

Timer Cleanup

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    if (!isRunning) return;  // No effect, no cleanup needed

    const intervalId = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);

    // Cleanup: clear interval when isRunning changes or component unmounts
    return () => clearInterval(intervalId);
  }, [isRunning]);

  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={() => setIsRunning(r => !r)}>
        {isRunning ? 'Pause' : 'Start'}
      </button>
      <button onClick={() => { setIsRunning(false); setSeconds(0); }}>
        Reset
      </button>
    </div>
  );
}

Subscription Cleanup

function WindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };

    window.addEventListener('resize', handleResize);

    // Cleanup: remove listener to prevent memory leaks
    return () => window.removeEventListener('resize', handleResize);
  }, []);  // Empty deps โ€” set up once, clean up on unmount

  return <p>{size.width} x {size.height}</p>;
}

WebSocket Cleanup

function LiveFeed({ channel }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const ws = new WebSocket(`wss://api.example.com/${channel}`);

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

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

    // Cleanup: close WebSocket when channel changes or component unmounts
    return () => {
      ws.close();
    };
  }, [channel]);

  return (
    <ul>
      {messages.map((msg, i) => (
        <li key={i}>{msg.text}</li>
      ))}
    </ul>
  );
}

Multiple useEffects

You can (and should) use multiple useEffect calls to separate unrelated logic. Each effect handles one concern.

function Dashboard({ userId }) {
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);

  // Effect 1: Fetch user data when userId changes
  useEffect(() => {
    let cancelled = false;

    fetchUser(userId).then(data => {
      if (!cancelled) setUser(data);
    });

    return () => { cancelled = true; };
  }, [userId]);

  // Effect 2: Subscribe to notifications (independent concern)
  useEffect(() => {
    const unsubscribe = subscribeToNotifications(userId, (notification) => {
      setNotifications(prev => [notification, ...prev]);
    });

    return () => unsubscribe();
  }, [userId]);

  // Effect 3: Update document title (independent concern)
  useEffect(() => {
    if (user) {
      document.title = `${user.name}'s Dashboard`;
    }
    return () => { document.title = 'App'; };
  }, [user]);

  // Effect 4: Log analytics (independent concern)
  useEffect(() => {
    analytics.logPageView('dashboard', { userId });
  }, [userId]);

  return (
    <div>
      <h1>{user?.name}</h1>
      <NotificationList items={notifications} />
    </div>
  );
}

Why Separate Effects?

BAD โ€” One giant effect mixing concerns:

useEffect(() => {
  fetchUser(userId);           // Fetch
  subscribe(userId);           // Subscribe
  document.title = '...';     // DOM
  analytics.log('...');       // Analytics
  return () => {
    unsubscribe();
    document.title = 'App';
  };
}, [userId]);

GOOD โ€” Separated by concern:

useEffect(() => { fetchUser(userId); }, [userId]);
useEffect(() => { subscribe(userId); return unsubscribe; }, [userId]);
useEffect(() => { document.title = '...'; }, [user]);
useEffect(() => { analytics.log('...'); }, [userId]);

Benefits:
- Each effect has its own cleanup
- Each can have different dependency arrays
- Easier to understand, test, and debug
- Can be extracted into custom hooks

Race Conditions

Race conditions happen when multiple async operations overlap and the results arrive out of order.

The Problem

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

  useEffect(() => {
    // User types "ab" -> fetch starts for "ab"
    // User types "abc" -> fetch starts for "abc"
    // "abc" response arrives first -> setResults(abcResults)
    // "ab" response arrives second -> setResults(abResults) <- STALE!
    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(data => setResults(data));
  }, [query]);

  return <ResultsList results={results} />;
}
Timeline of the race condition:

  t=0  query="ab"   -> fetch("/api/search?q=ab") starts
  t=1  query="abc"  -> fetch("/api/search?q=abc") starts
  t=2  "abc" response arrives  -> setResults(abcResults)  // correct
  t=3  "ab" response arrives   -> setResults(abResults)   // STALE! overwrites abc

Fix 1: Boolean Flag (Simple)

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

  useEffect(() => {
    let cancelled = false;

    fetch(`/api/search?q=${query}`)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setResults(data);  // Only update if this effect hasn't been cleaned up
        }
      });

    return () => {
      cancelled = true;  // Mark previous request as stale
    };
  }, [query]);

  return <ResultsList results={results} />;
}

AbortController actually cancels the network request, saving bandwidth and processing.

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

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

    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        setResults(data);
        setLoading(false);
      })
      .catch(err => {
        if (err.name === 'AbortError') {
          // Request was cancelled โ€” not an error
          return;
        }
        setError(err.message);
        setLoading(false);
      });

    return () => controller.abort();  // Cancel request on cleanup
  }, [query]);

  if (error) return <p>Error: {error}</p>;
  if (loading) return <p>Loading...</p>;
  return <ResultsList results={results} />;
}
With AbortController:

  t=0  query="ab"   -> fetch starts, controller_1 created
  t=1  query="abc"  -> controller_1.abort() called (cancels "ab" request)
                     -> fetch starts, controller_2 created
  t=2  "abc" response arrives -> setResults(abcResults)
  t=3  "ab" response NEVER arrives (aborted)

Fix 3: AbortController with Async/Await

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

  useEffect(() => {
    const controller = new AbortController();

    async function fetchUser() {
      setLoading(true);
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        const data = await res.json();
        setUser(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error('Fetch failed:', err);
        }
      } finally {
        if (!controller.signal.aborted) {
          setLoading(false);
        }
      }
    }

    fetchUser();

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

  if (loading) return <p>Loading...</p>;
  return <h1>{user?.name}</h1>;
}

Event Listener Lifecycle

Managing event listeners in React requires careful cleanup to avoid memory leaks and stale references.

Window/Document Events

function KeyboardShortcuts({ onSave, onUndo }) {
  useEffect(() => {
    const handleKeyDown = (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
        e.preventDefault();
        onSave();
      }
      if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
        e.preventDefault();
        onUndo();
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [onSave, onUndo]);

  return null;
}

Scroll Tracking

function ScrollProgress() {
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    const handleScroll = () => {
      const scrollTop = document.documentElement.scrollTop;
      const scrollHeight = document.documentElement.scrollHeight;
      const clientHeight = document.documentElement.clientHeight;
      const scrolled = scrollTop / (scrollHeight - clientHeight);
      setProgress(Math.round(scrolled * 100));
    };

    window.addEventListener('scroll', handleScroll, { passive: true });
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return (
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        width: `${progress}%`,
        height: 3,
        backgroundColor: '#3b82f6',
        transition: 'width 100ms',
      }}
    />
  );
}

Intersection Observer

function LazyImage({ src, alt, placeholder }) {
  const imgRef = useRef(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const element = imgRef.current;
    if (!element) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.unobserve(element);  // Stop observing once visible
        }
      },
      { threshold: 0.1 }
    );

    observer.observe(element);

    return () => observer.disconnect();
  }, []);

  return (
    <img
      ref={imgRef}
      src={isVisible ? src : placeholder}
      alt={alt}
      loading="lazy"
    />
  );
}

Media Query Listener

function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => {
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const mediaQuery = window.matchMedia(query);
    const handler = (event) => setMatches(event.matches);

    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

// Usage
function ResponsiveLayout() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');

  return (
    <div className={isMobile ? 'mobile-layout' : 'desktop-layout'}>
      <p>Theme: {prefersDark ? 'dark' : 'light'}</p>
    </div>
  );
}

Performance: When Effects Run

Avoiding Unnecessary Effects

Many effects can be replaced with simpler patterns.

// BAD: Using effect to derive state
function FilteredList({ items, filter }) {
  const [filtered, setFiltered] = useState([]);

  useEffect(() => {
    setFiltered(items.filter(item => item.category === filter));
  }, [items, filter]);
  // Problem: extra render โ€” first render shows stale data, then effect updates

  return <List items={filtered} />;
}

// GOOD: Calculate during render
function FilteredList({ items, filter }) {
  const filtered = items.filter(item => item.category === filter);
  // No effect, no extra render, always in sync

  return <List items={filtered} />;
}

// GOOD: useMemo if filtering is expensive
function FilteredList({ items, filter }) {
  const filtered = useMemo(
    () => items.filter(item => item.category === filter),
    [items, filter]
  );

  return <List items={filtered} />;
}

You Might Not Need an Effect

Instead of this effect...Do this instead
Transform data for renderingCalculate during render
Filter/sort a listuseMemo during render
Reset state when prop changesUse a key prop on the component
Update parent stateCall parent's callback in event handler
Synchronize two state variablesDerive one from the other
Initialize from propsUse state initializer function
POST on form submitHandle in submit event handler
// BAD: Effect to reset form when userId changes
function Profile({ userId }) {
  const [name, setName] = useState('');

  useEffect(() => {
    setName('');  // Reset on userId change
  }, [userId]);
}

// GOOD: Use key to remount
function ProfilePage({ userId }) {
  return <ProfileForm key={userId} userId={userId} />;
  // When userId changes, React unmounts old ProfileForm and mounts new one
  // State automatically resets
}

Debouncing Effects

function SearchInput() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

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

    const timeoutId = setTimeout(() => {
      fetch(`/api/search?q=${query}`)
        .then(res => res.json())
        .then(setResults);
    }, 300);

    return () => clearTimeout(timeoutId);
    // Every keystroke clears the previous timeout
    // Only the last one fires after 300ms of inactivity
  }, [query]);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ul>
        {results.map(r => <li key={r.id}>{r.name}</li>)}
      </ul>
    </div>
  );
}
Debounce timeline:

  t=0    type 'r'    -> setTimeout(fetch, 300)
  t=100  type 'e'    -> clearTimeout, setTimeout(fetch, 300)
  t=200  type 'a'    -> clearTimeout, setTimeout(fetch, 300)
  t=300  type 'c'    -> clearTimeout, setTimeout(fetch, 300)
  t=400  type 't'    -> clearTimeout, setTimeout(fetch, 300)
  t=700  (300ms idle) -> fetch('/api/search?q=react') fires

Custom Hooks with useEffect

useFetch

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!url) {
      setData(null);
      setLoading(false);
      return;
    }

    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        setData(data);
        setLoading(false);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setError(err.message);
          setLoading(false);
        }
      });

    return () => controller.abort();
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserList() {
  const { data: users, loading, error } = useFetch('/api/users');

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

useDocumentTitle

function useDocumentTitle(title) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = title;

    return () => {
      document.title = previousTitle;
    };
  }, [title]);
}

// Usage
function ProductPage({ product }) {
  useDocumentTitle(`${product.name} | Store`);

  return <h1>{product.name}</h1>;
}

useOnClickOutside

function useOnClickOutside(ref, handler) {
  useEffect(() => {
    const listener = (event) => {
      if (!ref.current || ref.current.contains(event.target)) {
        return;  // Click was inside the element
      }
      handler(event);
    };

    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);

    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [ref, handler]);
}

// Usage
function Dropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const dropdownRef = useRef(null);

  useOnClickOutside(dropdownRef, () => setIsOpen(false));

  return (
    <div ref={dropdownRef}>
      <button onClick={() => setIsOpen(o => !o)}>Menu</button>
      {isOpen && (
        <ul className="dropdown-menu">
          <li>Option 1</li>
          <li>Option 2</li>
          <li>Option 3</li>
        </ul>
      )}
    </div>
  );
}

useInterval

function useInterval(callback, delay) {
  const savedCallback = useRef(callback);

  // Always point to the latest callback
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay === null) return;  // Paused

    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

// Usage
function Stopwatch() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);

  useInterval(() => {
    setSeconds(s => s + 1);
  }, isRunning ? 1000 : null);  // null = paused

  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={() => setIsRunning(r => !r)}>
        {isRunning ? 'Pause' : 'Start'}
      </button>
      <button onClick={() => { setIsRunning(false); setSeconds(0); }}>
        Reset
      </button>
    </div>
  );
}

Strict Mode and Double Effects

In development with React.StrictMode, effects run twice (mount -> unmount -> mount). This helps catch bugs โ€” if your cleanup is correct, double-running should be safe.

// This breaks in Strict Mode if cleanup is wrong:
useEffect(() => {
  const connection = createConnection();
  connection.connect();
  // No cleanup! In Strict Mode: two connections opened, none closed
}, []);

// This works in Strict Mode:
useEffect(() => {
  const connection = createConnection();
  connection.connect();
  return () => connection.disconnect();  // Proper cleanup
}, []);
Strict Mode lifecycle:

  Mount:
    Effect runs          -> connection.connect()
    Cleanup runs         -> connection.disconnect()
    Effect runs again    -> connection.connect()

  Unmount:
    Cleanup runs         -> connection.disconnect()

Result: One connection open at a time. Correct!

Common Mistakes

1. Infinite Loop

// BUG: Creates a new array every render -> effect re-runs -> setState -> re-render
function Broken() {
  const [items, setItems] = useState([]);

  useEffect(() => {
    setItems([1, 2, 3]);  // New array -> re-render -> effect runs again -> ...
  });  // No dependency array = runs every render
}

// FIX: Add dependency array
useEffect(() => {
  setItems([1, 2, 3]);
}, []);  // Only on mount

2. Missing Cleanup

// BUG: Memory leak โ€” listener never removed
useEffect(() => {
  window.addEventListener('resize', handleResize);
}, []);

// FIX: Return cleanup function
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

3. Async Effect Function

// BUG: useEffect callback can't be async (returns a Promise, not a cleanup function)
useEffect(async () => {
  const data = await fetch('/api/data');
  // ...
}, []);

// FIX: Define async function inside effect
useEffect(() => {
  async function fetchData() {
    const data = await fetch('/api/data');
    // ...
  }
  fetchData();
}, []);

4. Object/Array in Dependencies

// BUG: New object every render -> effect runs every render
function Component({ userId }) {
  const config = { userId, limit: 10 };

  useEffect(() => {
    fetchData(config);
  }, [config]);  // config is new object every render
}

// FIX: Use primitive values as dependencies
function Component({ userId }) {
  useEffect(() => {
    fetchData({ userId, limit: 10 });
  }, [userId]);  // Only re-runs when userId changes
}

Effect Dependency Cheat Sheet

useEffect(fn)           -> Run after EVERY render
useEffect(fn, [])       -> Run once after MOUNT only
useEffect(fn, [a, b])   -> Run after mount AND when a or b changes
useEffect(fn => cleanup) -> Cleanup runs before re-run and on unmount

Dependency values:
  Primitives (string, number, boolean) -> compared by value
  Objects, arrays, functions           -> compared by reference
  Refs (useRef)                        -> NEVER put in deps (stable reference)
  setState functions                   -> NEVER put in deps (stable reference)

Key Takeaways

  1. useEffect runs after render โ€” it's for side effects, not for computing derived state
  2. The dependency array controls when โ€” empty means mount only, filled means "re-run when these change"
  3. Always clean up โ€” subscriptions, timers, event listeners, and connections
  4. Use AbortController for fetch requests to prevent race conditions
  5. Separate concerns โ€” use multiple useEffects for unrelated logic
  6. Don't ignore lint warnings about missing dependencies
  7. Functional state updates avoid the need to include state in dependencies
  8. Many effects can be eliminated โ€” calculate during render, use keys, or handle in event handlers
  9. Strict Mode double-fires effects โ€” this is intentional and helps you find cleanup bugs
  10. Extract custom hooks when effect patterns repeat (useFetch, useInterval, useOnClickOutside)

Found this helpful?

Support devsofus โ€” help us keep creating free dev guides.

Related Articles