Episode 2 — React Frontend Architecture NextJS / 2.4 — React Lifecycle Methods

2.4.c — useEffect Hook

In one sentence: useEffect lets functional components run side effects — data fetching, subscriptions, DOM manipulation, timers — by synchronising with external systems rather than reacting to lifecycle events.

Navigation: ← React's Lifecycle Methods · Next → Data Fetching, Cleanup & DOM Manipulation


Table of Contents

  1. Why useEffect Exists
  2. The Mental Model — Synchronisation, Not Lifecycle
  3. Basic Syntax
  4. The Three Dependency Configurations
  5. The Cleanup Function
  6. Multiple useEffects — Separation of Concerns
  7. useEffect Execution Order
  8. Common Patterns
  9. useEffect vs useLayoutEffect
  10. The Closure Trap — Stale Values in Effects
  11. Async in useEffect
  12. Strict Mode Double Invocation
  13. Rules of useEffect
  14. Lifecycle Method → useEffect Mapping
  15. When NOT to Use useEffect
  16. Key Takeaways

1. Why useEffect Exists

Before hooks, functional components couldn't:

  • Fetch data after mounting
  • Subscribe to events
  • Set up timers
  • Clean up resources

These are all side effects — they reach outside the component to interact with the external world (APIs, DOM, browser APIs, third-party libraries).

┌────────────────────────────────────────────────────────────┐
│  PURE COMPONENT WORLD              EXTERNAL WORLD          │
│                                                            │
│  Props + State                     APIs / fetch            │
│       ↓                            Browser APIs            │
│   render() → JSX    ←──useEffect──→ DOM measurements      │
│                                    Timers                  │
│  No side effects                   WebSocket connections   │
│  allowed in render                 localStorage            │
│                                    Event listeners         │
└────────────────────────────────────────────────────────────┘

useEffect is the bridge between React's pure rendering world and the messy external world.

Class components had three methods for this

class Example extends Component {
  componentDidMount()    { /* setup after first render */ }
  componentDidUpdate()   { /* react to changes */ }
  componentWillUnmount() { /* cleanup */ }
}

useEffect replaces all three

function Example() {
  useEffect(() => {
    // setup (componentDidMount + componentDidUpdate)
    return () => {
      // cleanup (componentWillUnmount + cleanup before re-run)
    };
  }, [dependencies]);
}

One API instead of three. But it's not just a replacement — it's a different way of thinking.


2. The Mental Model — Synchronisation, Not Lifecycle

The class mindset (lifecycle)

"WHEN does this happen?"
  Mount   → fetch user data
  Update  → if userId changed, refetch
  Unmount → cancel pending request

The hooks mindset (synchronisation)

"WHAT does this effect synchronise with?"
  → This effect synchronises the component with the user API
  → It depends on [userId]
  → React handles when to run/cleanup

Visual comparison

CLASS COMPONENT:
┌─────────────────────────────────────────────────────────┐
│  componentDidMount() {                                   │
│    this.fetchUser(this.props.userId);                    │
│  }                                                       │
│                                                          │
│  componentDidUpdate(prevProps) {                         │
│    if (prevProps.userId !== this.props.userId) {          │
│      this.fetchUser(this.props.userId);                  │
│    }                                                     │
│  }                                                       │
│                                                          │
│  componentWillUnmount() {                                │
│    this.controller.abort();                              │
│  }                                                       │
└─────────────────────────────────────────────────────────┘
  Three methods, logic SPLIT across lifecycle

FUNCTIONAL COMPONENT:
┌─────────────────────────────────────────────────────────┐
│  useEffect(() => {                                       │
│    const controller = new AbortController();             │
│    fetchUser(userId, controller.signal);                 │
│    return () => controller.abort();                      │
│  }, [userId]);                                           │
└─────────────────────────────────────────────────────────┘
  One effect, logic CO-LOCATED

The hooks version says: "Keep this component in sync with userId. Whenever userId changes, clean up the old effect and run a new one."


3. Basic Syntax

import { useEffect } from 'react';

function Component() {
  useEffect(
    () => {
      // Effect function — runs after render
      console.log('Effect ran!');

      return () => {
        // Cleanup function — runs before next effect and on unmount
        console.log('Cleanup ran!');
      };
    },
    [/* dependency array */]
  );

  return <div />;
}

Anatomy

useEffect(effectFunction, dependencyArray)
           │                │
           │                └── Optional array of values to watch
           │                    Determines WHEN effect re-runs
           │
           └── Function that runs the side effect
               Can optionally return a cleanup function

The simplest effect

function Logger() {
  useEffect(() => {
    console.log('Component rendered');
  });

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

No dependency array = runs after every render.


4. The Three Dependency Configurations

Configuration 1: No dependency array — runs every render

useEffect(() => {
  console.log('Runs after EVERY render');
});
Render 1 → Effect runs
Render 2 → Cleanup from render 1 → Effect runs
Render 3 → Cleanup from render 2 → Effect runs
...

Use case: Debugging, development logging. Rarely useful in production.

Configuration 2: Empty array [] — runs once on mount

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

  return () => {
    console.log('Runs once on unmount');
  };
}, []);  // Empty array = no dependencies to watch
Mount   → Effect runs
Render 2 → (nothing — no dependencies changed)
Render 3 → (nothing)
Unmount → Cleanup runs

Use case: One-time setup: fetch initial data, add global event listeners, initialize libraries.

Equivalent class code:

componentDidMount() { /* ... */ }
componentWillUnmount() { /* cleanup */ }

Configuration 3: With dependencies — runs when deps change

useEffect(() => {
  console.log(`userId changed to: ${userId}`);
  fetchUser(userId);

  return () => {
    console.log(`Cleaning up for userId: ${userId}`);
  };
}, [userId]);  // Only re-runs when userId changes
Mount (userId=1)         → Effect runs (userId=1)
Render (userId=1, other) → (nothing — userId didn't change)
Render (userId=2)        → Cleanup(userId=1) → Effect runs(userId=2)
Render (userId=2)        → (nothing — userId didn't change)
Unmount                  → Cleanup(userId=2)

Use case: React to specific prop or state changes.

Equivalent class code:

componentDidMount() { this.fetchUser(this.props.userId); }
componentDidUpdate(prevProps) {
  if (prevProps.userId !== this.props.userId) {
    this.fetchUser(this.props.userId);
  }
}

How React compares dependencies

React uses Object.is() to compare each dependency with its value from the last render:

Object.is(3, 3)                    // true — same number
Object.is('hello', 'hello')        // true — same string
Object.is(true, true)              // true — same boolean
Object.is(null, null)              // true — same null

Object.is({}, {})                  // false — different objects!
Object.is([], [])                  // false — different arrays!
Object.is(() => {}, () => {})      // false — different functions!

This is why objects, arrays, and functions in the dependency array cause effects to re-run every render unless memoised.

Dependency pitfalls

// ❌ BUG: New object every render → effect runs every render
function Search({ query }) {
  const options = { query, limit: 10 };  // New object each render

  useEffect(() => {
    fetchResults(options);
  }, [options]);  // Always "changed" because Object.is({...}, {...}) = false
}

// ✅ FIX 1: Use primitive dependencies
useEffect(() => {
  fetchResults({ query, limit: 10 });
}, [query]);  // String comparison — stable

// ✅ FIX 2: Memoize the object
const options = useMemo(() => ({ query, limit: 10 }), [query]);
useEffect(() => {
  fetchResults(options);
}, [options]);  // Same reference when query hasn't changed

5. The Cleanup Function

The cleanup function is the most important part of useEffect to understand correctly.

When does cleanup run?

1. Before the effect RE-RUNS (when dependencies change)
2. When the component UNMOUNTS
┌────────────────────────────────────────────────────────────┐
│  Mount                                                      │
│    Effect(deps=A) runs                                      │
│                                                             │
│  Deps change to B                                           │
│    Cleanup(deps=A) runs   ← Clean up OLD effect first       │
│    Effect(deps=B) runs    ← Then run NEW effect              │
│                                                             │
│  Deps change to C                                           │
│    Cleanup(deps=B) runs                                      │
│    Effect(deps=C) runs                                       │
│                                                             │
│  Unmount                                                     │
│    Cleanup(deps=C) runs   ← Final cleanup                    │
└────────────────────────────────────────────────────────────┘

Example: Event listener

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

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

    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);  // Cleanup!
    };
  }, []);  // Empty deps — set up once, clean up on unmount

  return <p>{size.w} × {size.h}</p>;
}

Example: Timer

function Stopwatch() {
  const [seconds, setSeconds] = useState(0);
  const [running, setRunning] = useState(false);

  useEffect(() => {
    if (!running) return;  // No effect when not running

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

    return () => clearInterval(id);  // Clean up when running changes or unmount
  }, [running]);

  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={() => setRunning(r => !r)}>
        {running ? 'Stop' : 'Start'}
      </button>
    </div>
  );
}

Example: Subscription with changing ID

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

  useEffect(() => {
    console.log(`Connecting to room ${roomId}`);
    const connection = createConnection(roomId);

    connection.on('message', (msg) => {
      setMessages(prev => [...prev, msg]);
    });

    connection.connect();

    return () => {
      console.log(`Disconnecting from room ${roomId}`);
      connection.disconnect();  // Clean up OLD connection before connecting to new room
    };
  }, [roomId]);

  return <MessageList messages={messages} />;
}

// When roomId changes from "general" to "random":
// 1. Cleanup: "Disconnecting from room general"
// 2. Effect:  "Connecting to room random"

What if you forget cleanup?

// ❌ MEMORY LEAK — listener accumulates on every render
function Bad() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    window.addEventListener('mousemove', handleMove);
    // No cleanup! Every render adds ANOTHER listener
  });

  return <p>{count}</p>;
}

// After 10 renders → 10 mousemove listeners running simultaneously

Rule of thumb

If your effect "connects" to something, it needs a cleanup that "disconnects".

SetupCleanup
addEventListenerremoveEventListener
setIntervalclearInterval
setTimeoutclearTimeout
connection.connect()connection.disconnect()
observer.observe()observer.disconnect()
fetch with AbortControllercontroller.abort()
subscribe()unsubscribe()

6. Multiple useEffects — Separation of Concerns

Unlike class components where all side effects are lumped into componentDidMount and componentDidUpdate, hooks let you separate unrelated effects:

Class — everything mixed together

class ChatRoom extends Component {
  componentDidMount() {
    // Three unrelated things in one method:
    this.connectToChat(this.props.roomId);          // 1. Chat connection
    document.title = `Room: ${this.props.roomId}`;  // 2. Document title
    this.logAnalytics('room_opened');                // 3. Analytics
  }

  componentDidUpdate(prevProps) {
    if (prevProps.roomId !== this.props.roomId) {
      this.disconnectFromChat(prevProps.roomId);     // 1. Chat
      this.connectToChat(this.props.roomId);
      document.title = `Room: ${this.props.roomId}`; // 2. Title
      this.logAnalytics('room_changed');              // 3. Analytics
    }
  }

  componentWillUnmount() {
    this.disconnectFromChat(this.props.roomId);      // 1. Chat only
  }
}

Hooks — separated by concern

function ChatRoom({ roomId }) {
  // Effect 1: Chat connection
  useEffect(() => {
    const connection = connectToChat(roomId);
    return () => connection.disconnect();
  }, [roomId]);

  // Effect 2: Document title
  useEffect(() => {
    document.title = `Room: ${roomId}`;
  }, [roomId]);

  // Effect 3: Analytics
  useEffect(() => {
    logAnalytics('room_opened');
  }, [roomId]);

  return <Chat roomId={roomId} />;
}

Each effect:

  • Has its own dependencies
  • Has its own cleanup
  • Can be extracted into a custom hook

Extract to custom hooks

function ChatRoom({ roomId }) {
  useChatConnection(roomId);    // Custom hook
  useDocumentTitle(`Room: ${roomId}`);
  useAnalytics('room_opened', { roomId });

  return <Chat roomId={roomId} />;
}

7. useEffect Execution Order

When does useEffect actually run?

1. React renders the component (calls the function, gets JSX)
2. React updates the DOM (applies changes to real DOM nodes)
3. Browser paints the screen (user sees the update)
4. useEffect runs                ← AFTER paint (asynchronous)

This means:

  • ✅ The DOM is ready when useEffect runs
  • ✅ The user sees the initial render before effects run
  • ❌ There may be a brief visual flicker if effect changes the DOM

Multiple effects run in declaration order

function Multi() {
  useEffect(() => { console.log('Effect 1'); }, []);
  useEffect(() => { console.log('Effect 2'); }, []);
  useEffect(() => { console.log('Effect 3'); }, []);

  // Output: Effect 1, Effect 2, Effect 3 (in order)
}

Cleanup runs in declaration order too

// When dependencies change or component unmounts:
// Cleanup 1 → Cleanup 2 → Cleanup 3 → Effect 1 → Effect 2 → Effect 3

Parent vs child order

function Parent() {
  useEffect(() => { console.log('Parent effect'); }, []);
  return <Child />;
}

function Child() {
  useEffect(() => { console.log('Child effect'); }, []);
  return <div />;
}

// Output:
// Child effect    ← Children first (bottom-up)
// Parent effect

Effects fire bottom-up (children before parents), same as componentDidMount.


8. Common Patterns

Pattern 1: Fetch data on mount

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

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

    async function fetchUser() {
      setLoading(true);
      setError(null);
      try {
        const res = await fetch(`/api/users/${userId}`);
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        if (!cancelled) {
          setUser(data);
          setLoading(false);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
          setLoading(false);
        }
      }
    }

    fetchUser();

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

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;
  return <Profile user={user} />;
}

Pattern 2: Event listener

function useKeyPress(targetKey) {
  const [pressed, setPressed] = useState(false);

  useEffect(() => {
    const down = (e) => { if (e.key === targetKey) setPressed(true); };
    const up = (e) => { if (e.key === targetKey) setPressed(false); };

    window.addEventListener('keydown', down);
    window.addEventListener('keyup', up);

    return () => {
      window.removeEventListener('keydown', down);
      window.removeEventListener('keyup', up);
    };
  }, [targetKey]);

  return pressed;
}

Pattern 3: Document title

function useDocumentTitle(title) {
  useEffect(() => {
    const prevTitle = document.title;
    document.title = title;
    return () => { document.title = prevTitle; };
  }, [title]);
}

// Usage
function Page({ name }) {
  useDocumentTitle(`${name} | MyApp`);
  return <div>{name}</div>;
}

Pattern 4: LocalStorage sync

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>{theme}</button>;
}

Pattern 5: Debounced search

function SearchInput({ onSearch }) {
  const [query, setQuery] = useState('');

  useEffect(() => {
    const timer = setTimeout(() => {
      if (query.trim()) {
        onSearch(query);
      }
    }, 300);

    return () => clearTimeout(timer);  // Cancel pending search on new keystroke
  }, [query, onSearch]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

Pattern 6: Media query

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

  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e) => setMatches(e.matches);

    mql.addEventListener('change', handler);
    setMatches(mql.matches);  // Sync initial value

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

  return matches;
}

// Usage
function Responsive() {
  const isMobile = useMediaQuery('(max-width: 768px)');
  return <div>{isMobile ? <MobileNav /> : <DesktopNav />}</div>;
}

9. useEffect vs useLayoutEffect

Both have the same API. The difference is when they fire:

React renders component
    ↓
React updates DOM
    ↓
┌─ useLayoutEffect fires ─┐    ← BEFORE browser paint (synchronous)
│  Can read/modify DOM     │
│  BLOCKS painting         │
└──────────────────────────┘
    ↓
Browser paints screen          ← User sees the result
    ↓
┌─ useEffect fires ──────┐    ← AFTER browser paint (asynchronous)
│  Can't prevent flicker  │
│  Non-blocking           │
└─────────────────────────┘

When to use useLayoutEffect

// ❌ Flickers — useEffect runs after paint
function Tooltip({ targetRef, content }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });

  useEffect(() => {
    const rect = targetRef.current.getBoundingClientRect();
    setPosition({ top: rect.bottom, left: rect.left });
  }, [targetRef]);
  // User briefly sees tooltip at (0,0) before it jumps to correct position

  return <div style={{ position: 'absolute', ...position }}>{content}</div>;
}

// ✅ No flicker — useLayoutEffect runs before paint
function Tooltip({ targetRef, content }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });

  useLayoutEffect(() => {
    const rect = targetRef.current.getBoundingClientRect();
    setPosition({ top: rect.bottom, left: rect.left });
  }, [targetRef]);
  // Position is set before user sees anything

  return <div style={{ position: 'absolute', ...position }}>{content}</div>;
}

Decision guide

Use useEffectUse useLayoutEffect
Data fetchingDOM measurements for positioning
Event subscriptionsPreventing visual flicker
TimersSynchronous DOM mutations
AnalyticsThird-party DOM library init
Document titleFocus management
99% of cases1% of cases

Default to useEffect. Only switch to useLayoutEffect if you see a visual flicker.


10. The Closure Trap — Stale Values in Effects

This is the #1 gotcha with hooks. Effects "capture" the values from the render they were created in.

The problem

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

  useEffect(() => {
    const id = setInterval(() => {
      console.log('Count is:', count);  // Always 0!
      setCount(count + 1);              // Always sets to 1!
    }, 1000);

    return () => clearInterval(id);
  }, []);  // Empty deps = effect runs once, captures count=0 forever

  return <p>{count}</p>;
  // Display shows: 1, 1, 1, 1... (never increments past 1)
}

Why it happens

Render 1 (count=0):
  useEffect captures count=0 in closure
  setInterval callback always sees count=0
  setCount(0 + 1) → always sets to 1

Render 2 (count=1):
  useEffect doesn't re-run (empty deps)
  Old setInterval still sees count=0 from render 1

Fix 1: Add dependency

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);
// Effect re-runs when count changes
// But: creates new interval every second (slightly wasteful)

Fix 2: Use updater function (BEST)

useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1);  // Doesn't need current count!
  }, 1000);
  return () => clearInterval(id);
}, []);
// Empty deps is fine because we don't READ count, we use the updater

Fix 3: useRef for latest value

function Timer() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count;  // Always keep ref in sync
  }, [count]);

  useEffect(() => {
    const id = setInterval(() => {
      console.log('Latest count:', countRef.current);  // Always fresh!
      setCount(prev => prev + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <p>{count}</p>;
}

How closures work in effects — mental model

Each render creates a SNAPSHOT:

Render 1: { count: 0, handler: function() { /* sees count=0 */ } }
Render 2: { count: 1, handler: function() { /* sees count=1 */ } }
Render 3: { count: 2, handler: function() { /* sees count=2 */ } }

useEffect with [] only runs the handler from Render 1
useEffect with [count] runs the handler from the LATEST render

11. Async in useEffect

You can't make the effect callback async

// ❌ This doesn't work as expected
useEffect(async () => {
  const data = await fetchData();
  setData(data);
}, []);

// React expects useEffect to return undefined or a cleanup function
// async functions return a Promise — React ignores it as cleanup

Fix 1: Define async function inside

useEffect(() => {
  async function loadData() {
    const data = await fetchData();
    setData(data);
  }
  loadData();
}, []);

Fix 2: IIFE pattern

useEffect(() => {
  (async () => {
    const data = await fetchData();
    setData(data);
  })();
}, []);

Fix 3: .then() chain

useEffect(() => {
  fetchData()
    .then(data => setData(data))
    .catch(err => setError(err.message));
}, []);

Complete async pattern with cleanup

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

  async function fetchUser() {
    try {
      setLoading(true);
      const res = await fetch(`/api/users/${userId}`, {
        signal: controller.signal,
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data = await res.json();
      setUser(data);
      setError(null);
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err.message);
      }
    } finally {
      if (!controller.signal.aborted) {
        setLoading(false);
      }
    }
  }

  fetchUser();

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

12. Strict Mode Double Invocation

In development, React Strict Mode intentionally runs effects twice:

Development (Strict Mode):
  Mount → Effect → Cleanup → Effect

Production:
  Mount → Effect

Why React does this

To help you find missing cleanup functions:

// ❌ This bug is HIDDEN without strict mode
useEffect(() => {
  const connection = connect(roomId);
  // Forgot cleanup!
}, [roomId]);

// In strict mode:
// Mount → connect(roomId)  → disconnect? NO CLEANUP! → connect(roomId)
// Now you have TWO active connections — the bug is visible
// ✅ Correct — works fine with double invocation
useEffect(() => {
  const connection = connect(roomId);
  return () => connection.disconnect();  // Cleanup prevents double connection
}, [roomId]);

// In strict mode:
// Mount → connect → disconnect → connect
// Only ONE active connection — correct!

How to handle it

// ❌ Bad — breaks with strict mode
useEffect(() => {
  let count = 0;
  const id = setInterval(() => {
    count++;  // Accumulates incorrectly with double invocation
    console.log(count);
  }, 1000);
}, []);

// ✅ Good — resilient to double invocation
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);  // Cleanup prevents duplication
}, []);

Don't "fix" by removing Strict Mode

// ❌ Don't do this
<React.StrictMode>  {/* Don't remove this! */}
  <App />
</React.StrictMode>

// Strict Mode helps catch bugs. If your code breaks with it,
// the code has a bug — fix the code, not the mode.

13. Rules of useEffect

Rule 1: Only call at the top level

// ❌ Conditional useEffect
function Bad({ shouldFetch }) {
  if (shouldFetch) {
    useEffect(() => { fetchData(); }, []);  // Hooks must not be conditional
  }
}

// ✅ Condition inside the effect
function Good({ shouldFetch }) {
  useEffect(() => {
    if (shouldFetch) {
      fetchData();
    }
  }, [shouldFetch]);
}

Rule 2: Only call in React functions

// ❌ Not in regular functions
function helper() {
  useEffect(() => { /* ... */ });  // Error!
}

// ✅ In components or custom hooks
function useHelper() {
  useEffect(() => { /* ... */ });  // Custom hook — OK
}

Rule 3: Include all dependencies

// ❌ Missing dependency — stale closure bug
function Search({ query }) {
  useEffect(() => {
    fetchResults(query);  // Uses `query` but not in deps
  }, []);

  // ✅ Include it
  useEffect(() => {
    fetchResults(query);
  }, [query]);
}

The react-hooks/exhaustive-deps ESLint rule enforces this automatically.

Rule 4: Don't lie about dependencies

// ❌ "I know what I'm doing" — you don't
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);  // Uses `count` but not in deps
  }, 1000);
  return () => clearInterval(id);
}, []);  // Lied about deps — `count` should be here

// ✅ Use updater function to avoid the dependency
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);  // No external dependency
  }, 1000);
  return () => clearInterval(id);
}, []);  // Honestly empty — doesn't READ any reactive values

14. Lifecycle Method → useEffect Mapping

componentDidMount

// Class
componentDidMount() {
  fetchData();
  document.addEventListener('click', this.handleClick);
}

// Hook
useEffect(() => {
  fetchData();
  document.addEventListener('click', handleClick);
  return () => document.removeEventListener('click', handleClick);
}, []);

componentDidUpdate (specific prop)

// Class
componentDidUpdate(prevProps) {
  if (prevProps.userId !== this.props.userId) {
    fetchUser(this.props.userId);
  }
}

// Hook
useEffect(() => {
  fetchUser(userId);
}, [userId]);

componentWillUnmount

// Class
componentWillUnmount() {
  clearInterval(this.timerId);
  this.ws.close();
}

// Hook
useEffect(() => {
  const timerId = setInterval(tick, 1000);
  const ws = new WebSocket(url);

  return () => {
    clearInterval(timerId);
    ws.close();
  };
}, []);

componentDidMount + componentWillUnmount combined

// Class — split across two methods
componentDidMount() {
  this.observer = new IntersectionObserver(this.handleIntersect);
  this.observer.observe(this.ref.current);
}
componentWillUnmount() {
  this.observer.disconnect();
}

// Hook — co-located in one place
useEffect(() => {
  const observer = new IntersectionObserver(handleIntersect);
  observer.observe(ref.current);
  return () => observer.disconnect();
}, []);

Full mapping table

Class MethoduseEffect Equivalent
componentDidMountuseEffect(() => {...}, [])
componentDidUpdate (all updates)useEffect(() => {...}) (no deps)
componentDidUpdate (specific)useEffect(() => {...}, [dep])
componentWillUnmountuseEffect(() => { return () => {...} }, [])
componentDidMount + componentWillUnmountuseEffect(() => { ...setup; return () => ...cleanup }, [])
componentDidMount + componentDidUpdate + componentWillUnmountuseEffect(() => { ...setup; return () => ...cleanup }, [deps])

15. When NOT to Use useEffect

React's documentation explicitly warns against several common misuses:

1. Don't derive state from props

// ❌ useEffect to sync state with props
function Bad({ items }) {
  const [filteredItems, setFilteredItems] = useState([]);

  useEffect(() => {
    setFilteredItems(items.filter(i => i.active));
  }, [items]);
  // Causes TWO renders: one with stale data, one with filtered data

  // ✅ Just compute it during render
  const filteredItems = items.filter(i => i.active);
  // Or memoize if expensive:
  const filteredItems = useMemo(() => items.filter(i => i.active), [items]);
}

2. Don't handle events

// ❌ useEffect to respond to a button click
function Bad() {
  const [submitted, setSubmitted] = useState(false);

  useEffect(() => {
    if (submitted) {
      sendAnalytics('form_submitted');
    }
  }, [submitted]);

  return <button onClick={() => setSubmitted(true)}>Submit</button>;

  // ✅ Just handle it in the event handler
  return (
    <button onClick={() => {
      setSubmitted(true);
      sendAnalytics('form_submitted');
    }}>
      Submit
    </button>
  );
}

3. Don't reset state on prop change

// ❌ useEffect to reset state
function ProfileEditor({ userId }) {
  const [name, setName] = useState('');

  useEffect(() => {
    setName('');
  }, [userId]);

  // ✅ Use key to remount with fresh state
  // In parent:
  // <ProfileEditor key={userId} userId={userId} />
}

4. Don't initialise the app

// ❌ useEffect for one-time app setup
function App() {
  useEffect(() => {
    initializeAnalytics();
    loadConfig();
  }, []);
}

// ✅ Do it outside of components
initializeAnalytics();
loadConfig();

const root = createRoot(document.getElementById('root'));
root.render(<App />);

Decision flowchart

Do I need to do something?
    │
    ├── In response to a user EVENT? → Handle in event handler
    │
    ├── To compute a value from props/state? → Compute during render (useMemo if expensive)
    │
    ├── To synchronize with an EXTERNAL system? → useEffect ✅
    │   (API, DOM, timer, WebSocket, browser API)
    │
    └── To initialize something once? → Do it outside the component

16. Key Takeaways

  1. useEffect = synchronisation, not lifecycle. Think "what does this effect sync with?" not "when does this run?"

  2. Three configurations: No deps (every render), empty [] (mount only), [deps] (when deps change).

  3. Cleanup is critical. Every connect needs a disconnect. Cleanup runs before re-run and on unmount.

  4. Separate concerns into multiple effects. Each effect has its own deps and cleanup.

  5. Effects run after paint (asynchronous). Use useLayoutEffect only if you see visual flicker.

  6. Closures capture values from the render they were created in. Use updater functions or refs to get the latest value.

  7. Can't use async directly on the effect callback. Define an async function inside instead.

  8. Strict Mode double-invocation reveals missing cleanup. Fix the code, not the mode.

  9. Don't lie about dependencies. The exhaustive-deps ESLint rule is your friend.

  10. Don't overuse useEffect. Derived values → compute in render. Events → handle in handlers. App init → do outside components.


Explain-It Challenge

  1. The Newspaper Subscription: useEffect's dependency array is like a newspaper subscription form. An empty array [] means "deliver once and cancel when I move out." Array with [topic] means "deliver whenever this topic publishes a new edition, and cancel the old subscription first." No array at all means "deliver every morning, no matter what." How does the cleanup function map to "cancelling the old subscription before starting a new one"?

  2. The Security Camera: Think of useEffect as a security camera you install in a room. It starts recording when you set it up (effect), and you need to turn it off when you leave (cleanup). If you're monitoring room 101 and then switch to room 102, you must first turn off the camera in 101 (cleanup), then set up a new camera in 102 (effect). What happens if you forget to turn off cameras and keep switching rooms? How does this relate to memory leaks?

  3. The Stale Snapshot: Closures are like photographs — they capture a moment in time. If you take a photo (effect) of your count=0, that photo will always show 0, even if the real count has changed to 100. The useRef approach is like a live video feed instead. Explain why setCount(prev => prev + 1) works without needing the "live feed" — what makes it different from setCount(count + 1)?


Navigation: ← React's Lifecycle Methods · Next → Data Fetching, Cleanup & DOM Manipulation