Episode 2 — React Frontend Architecture NextJS / 2.8 — useEffect Deep Dive

2.8.a — What useEffect Really Does

In one sentence: useEffect is not a lifecycle method — it is a synchronisation mechanism that keeps your React component connected to external systems outside React's rendering model.

Navigation: ← Overview · Next → Dependency Array Behaviour


Table of Contents

  1. The Mental Model Shift
  2. What Is an "Effect"?
  3. The Synchronisation Mental Model
  4. How useEffect Actually Executes
  5. The Render-and-Commit Pipeline
  6. useEffect vs Event Handlers
  7. What Belongs in useEffect
  8. What Does NOT Belong in useEffect
  9. Effects Are Closures Over Each Render
  10. The Component Lifecycle Through Effects
  11. useEffect vs useLayoutEffect
  12. Effects in Strict Mode
  13. The "You Might Not Need an Effect" Principle
  14. Real-World Mental Model: Chat Room Connection
  15. Key Takeaways

1. The Mental Model Shift

Most developers learn useEffect as a replacement for class lifecycle methods:

componentDidMount    → useEffect(() => {}, [])
componentDidUpdate   → useEffect(() => {})
componentWillUnmount → useEffect(() => { return () => {} }, [])

This mapping is wrong. It leads to buggy code, unnecessary effects, and confused reasoning.

The Correct Mental Model

┌─────────────────────────────────────────────────┐
│           WRONG: Lifecycle Thinking              │
│                                                  │
│  "When does this code run?"                      │
│  - On mount?                                     │
│  - On update?                                    │
│  - On unmount?                                   │
│                                                  │
├─────────────────────────────────────────────────┤
│          CORRECT: Synchronisation Thinking       │
│                                                  │
│  "What external system am I synchronising with?" │
│  - What data does the effect need?               │
│  - How do I start synchronising?                 │
│  - How do I stop synchronising?                  │
│                                                  │
└─────────────────────────────────────────────────┘

Think of useEffect as telling React:

"After you render, keep this external system synchronised with the current state of the component."

Why the Lifecycle Model Fails

// LIFECYCLE THINKING — leads to bugs
function ChatRoom({ roomId }) {
  useEffect(() => {
    // "Run on mount and when roomId changes"
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}
// SYNCHRONISATION THINKING — same code, better reasoning
function ChatRoom({ roomId }) {
  useEffect(() => {
    // "Keep connection synchronised with current roomId"
    // START synchronising
    const connection = createConnection(roomId);
    connection.connect();
    // STOP synchronising (cleanup)
    return () => connection.disconnect();
  }, [roomId]); // Re-synchronise when roomId changes
}

The code is identical, but the reasoning is completely different. When you think in synchronisation:

Lifecycle ThinkingSynchronisation Thinking
"When does this run?""What am I syncing with?"
Mount, update, unmountStart syncing, stop syncing
Three separate eventsOne continuous connection
"Does it run on mount?""Does it sync with the right value?"
Easy to miss edge casesNaturally handles all cases

2. What Is an "Effect"?

In React's model, there are three kinds of code:

1. Rendering Code (Pure)

Code that transforms props and state into JSX. Must be pure — no side effects.

function Greeting({ name }) {
  // RENDERING CODE — pure calculation
  const message = `Hello, ${name}!`;
  return <h1>{message}</h1>;
}

2. Event Handlers (User-Triggered)

Code that runs in response to user actions. Can have side effects.

function SendButton({ message }) {
  // EVENT HANDLER — runs when user clicks
  function handleClick() {
    sendMessage(message); // Side effect: network request
    showToast('Message sent!'); // Side effect: UI update
  }
  return <button onClick={handleClick}>Send</button>;
}

3. Effects (Render-Triggered)

Code that runs because something rendered, not because the user did something.

function ChatRoom({ roomId }) {
  // EFFECT — runs because the component rendered with this roomId
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

The Key Distinction

┌───────────────────┐     ┌───────────────────┐     ┌───────────────────┐
│   Rendering Code  │     │   Event Handlers   │     │     Effects       │
├───────────────────┤     ├───────────────────┤     ├───────────────────┤
│ Triggered by:     │     │ Triggered by:      │     │ Triggered by:     │
│ React calling     │     │ User interaction   │     │ Rendering itself  │
│ your component    │     │ (click, type, etc) │     │                   │
│                   │     │                    │     │                   │
│ Must be:          │     │ Can have:          │     │ Can have:         │
│ PURE (no effects) │     │ Side effects       │     │ Side effects      │
│                   │     │                    │     │                   │
│ Examples:         │     │ Examples:          │     │ Examples:         │
│ - Calculate JSX   │     │ - Send message     │     │ - Connect to API  │
│ - Filter data     │     │ - Navigate         │     │ - Start timer     │
│ - Format strings  │     │ - Update state     │     │ - Sync DOM        │
└───────────────────┘     └───────────────────┘     └───────────────────┘

When Is Code an "Effect"?

Ask yourself: "Is this side effect caused by the user doing something, or by the component appearing on screen?"

ScenarioCauseWhere to Put It
User clicks "Send"User actionEvent handler
Component needs to fetch user data when it appearsRenderinguseEffect
User types in search boxUser actionEvent handler (then maybe useEffect for debounce)
Component needs to connect to WebSocketRenderinguseEffect
User submits a formUser actionEvent handler
Document title should match current pageRenderinguseEffect
Analytics event when page loadsRenderinguseEffect

3. The Synchronisation Mental Model

useEffect synchronises your component with some external system. Think of it like keeping two things in sync:

┌─────────────────────────┐         ┌─────────────────────────┐
│     React Component     │  sync   │    External System      │
│                         │ ──────► │                         │
│  state: roomId = "abc"  │         │  WebSocket connection   │
│  state: theme = "dark"  │         │  Document title         │
│  props: userId = 42     │         │  Timer / interval       │
│                         │         │  Browser API            │
│                         │  ◄───── │  Third-party library    │
│                         │  events │                         │
└─────────────────────────┘         └─────────────────────────┘

The Three Parts of Every Effect

Every well-written useEffect has exactly three parts:

useEffect(() => {
  // 1. SETUP: Start synchronising
  //    Connect to the external system using current props/state
  const connection = createConnection(roomId);
  connection.connect();
  
  // 2. CLEANUP: Stop synchronising (optional return)
  //    Disconnect from the external system
  return () => {
    connection.disconnect();
  };
  
  // 3. DEPENDENCIES: When to re-synchronise
  //    React re-runs the effect when these values change
}, [roomId]);

The Synchronisation Cycle

Component renders with roomId = "general"
  │
  ▼
Effect runs SETUP: connect to "general"
  │
  ├─── User is in "general" room ───────────────────────┐
  │                                                       │
  ▼                                                       │
roomId changes to "random"                                │
  │                                                       │
  ▼                                                       │
Effect runs CLEANUP: disconnect from "general" ◄──────────┘
  │
  ▼
Effect runs SETUP: connect to "random"
  │
  ├─── User is in "random" room ────────────────────────┐
  │                                                       │
  ▼                                                       │
Component unmounts                                        │
  │                                                       │
  ▼                                                       │
Effect runs CLEANUP: disconnect from "random" ◄───────────┘

Notice: React doesn't think in terms of mount/unmount. It thinks in terms of start syncing and stop syncing.


4. How useEffect Actually Executes

Timing: After Paint

useEffect runs after the browser has painted the screen:

Component function called (render phase)
  │
  ▼
React calculates what changed (reconciliation)
  │
  ▼
React updates the DOM (commit phase)
  │
  ▼
Browser paints the screen ← USER SEES THE UPDATE
  │
  ▼
React runs useEffect callbacks ← EFFECTS RUN HERE

This is important: the user sees the new UI before effects run. This means:

function Profile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    // This runs AFTER the browser paints
    // The user already sees "Loading..." before this fires
    fetchUser(userId).then(setUser);
  }, [userId]);
  
  if (!user) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}

Why After Paint?

  1. Non-blocking: Effects don't delay the visual update
  2. Better UX: Users see the new UI immediately
  3. Performance: Heavy effects don't cause jank

Execution Order with Multiple Effects

function App() {
  useEffect(() => {
    console.log('Effect 1'); // Runs second
    return () => console.log('Cleanup 1'); // Runs on next render
  });
  
  useEffect(() => {
    console.log('Effect 2'); // Runs third
    return () => console.log('Cleanup 2');
  });
  
  console.log('Render'); // Runs first
}

// First render:
// "Render"
// "Effect 1"
// "Effect 2"

// Second render:
// "Render"
// "Cleanup 1"  ← Previous cleanups run first
// "Cleanup 2"
// "Effect 1"   ← Then new effects
// "Effect 2"

The Complete Execution Order

MOUNT:
  1. Parent renders
  2. Child renders
  3. Child effects run (children first!)
  4. Parent effects run

UPDATE:
  1. Parent renders
  2. Child renders
  3. Child previous cleanups run
  4. Parent previous cleanups run
  5. Child new effects run
  6. Parent new effects run

UNMOUNT:
  1. Child cleanups run
  2. Parent cleanups run

Key insight: Children's effects run before parents'. This is bottom-up.


5. The Render-and-Commit Pipeline

Understanding where useEffect fits in React's pipeline:

┌──────────────────────────────────────────────────────────────────┐
│                        RENDER PHASE                              │
│  (Pure — can be interrupted, restarted, abandoned)               │
│                                                                  │
│  1. React calls your component function                          │
│  2. Component returns JSX                                        │
│  3. React compares new JSX with previous JSX (diffing)           │
│  4. React calculates the minimal DOM changes needed              │
│                                                                  │
├──────────────────────────────────────────────────────────────────┤
│                        COMMIT PHASE                              │
│  (Synchronous — cannot be interrupted)                           │
│                                                                  │
│  5. React applies DOM changes                                    │
│  6. React updates refs (ref.current = DOM node)                  │
│  7. React runs useLayoutEffect callbacks (sync, before paint)    │
│                                                                  │
├──────────────────────────────────────────────────────────────────┤
│                        BROWSER PAINT                             │
│                                                                  │
│  8. Browser paints the updated screen                            │
│                                                                  │
├──────────────────────────────────────────────────────────────────┤
│                        EFFECT PHASE                              │
│  (Asynchronous — after paint)                                    │
│                                                                  │
│  9. React runs useEffect cleanup (from previous render)          │
│  10. React runs useEffect setup (from current render)            │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

Why This Order Matters

function Tooltip({ targetRef, text }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  
  // ❌ useEffect — user sees tooltip at (0,0) then it jumps
  useEffect(() => {
    const rect = targetRef.current.getBoundingClientRect();
    setPosition({ top: rect.top - 30, left: rect.left });
  }, [targetRef]);
  
  // ✅ useLayoutEffect — measurement happens before paint
  // User never sees the wrong position
  useLayoutEffect(() => {
    const rect = targetRef.current.getBoundingClientRect();
    setPosition({ top: rect.top - 30, left: rect.left });
  }, [targetRef]);
  
  return <div style={{ position: 'absolute', ...position }}>{text}</div>;
}

6. useEffect vs Event Handlers

One of the most important distinctions in React:

The Decision Question

"Does this code need to run because something specific happened (user action), or because the component is displaying on screen?"

function ProductPage({ productId }) {
  // ✅ Effect: needs to sync product data when component displays
  useEffect(() => {
    fetchProduct(productId).then(setProduct);
  }, [productId]);
  
  // ✅ Event handler: runs because user clicked "Buy"
  function handleBuy() {
    addToCart(productId);
  }
  
  // ❌ WRONG: Using effect for something caused by user action
  // useEffect(() => {
  //   if (justBought) {
  //     showConfirmation();
  //     setJustBought(false);
  //   }
  // }, [justBought]);
  
  // ✅ RIGHT: Put it in the event handler
  function handleBuy() {
    addToCart(productId);
    showConfirmation(); // Directly in the handler
  }
}

Comparison Table

AspectEvent HandleruseEffect
Triggered bySpecific user actionComponent rendering
TimingImmediately on interactionAfter browser paint
Cause"The user clicked Buy""The component is showing productId=5"
ExamplesSend message, navigate, submit formFetch data, connect WebSocket, sync title
Can set state?YesYes (but think twice)
Common mistakeN/AUsing effect for user-triggered actions

The Analytics Trap

// Scenario: Log when user views a product
function ProductPage({ productId }) {
  // ✅ Effect: logging happens because component is displaying
  useEffect(() => {
    logProductView(productId);
  }, [productId]);
  
  // But what about "Buy" button analytics?
  function handleBuy() {
    addToCart(productId);
    // ✅ Event handler: logging happens because user clicked
    logPurchase(productId);
  }
}

7. What Belongs in useEffect

Legitimate Use Cases

// 1. NETWORK CONNECTIONS
useEffect(() => {
  const ws = new WebSocket(`wss://chat.example.com/${roomId}`);
  ws.onmessage = (event) => setMessages(prev => [...prev, event.data]);
  return () => ws.close();
}, [roomId]);

// 2. DOM SUBSCRIPTIONS
useEffect(() => {
  function handleResize() {
    setWindowSize({ width: window.innerWidth, height: window.innerHeight });
  }
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

// 3. TIMERS
useEffect(() => {
  const id = setInterval(() => setSeconds(s => s + 1), 1000);
  return () => clearInterval(id);
}, []);

// 4. DOCUMENT / BROWSER API SYNCHRONISATION
useEffect(() => {
  document.title = `${unreadCount} messages`;
}, [unreadCount]);

// 5. THIRD-PARTY LIBRARY INTEGRATION
useEffect(() => {
  const chart = new Chart(canvasRef.current, config);
  return () => chart.destroy();
}, [data]);

// 6. INTERSECTION OBSERVER
useEffect(() => {
  const observer = new IntersectionObserver(
    ([entry]) => setIsVisible(entry.isIntersecting),
    { threshold: 0.5 }
  );
  observer.observe(elementRef.current);
  return () => observer.disconnect();
}, []);

The Common Pattern

Every legitimate useEffect connects to something outside React's control:

┌────────────────────────────────┐
│  Things outside React's        │
│  rendering model:              │
│                                │
│  • Browser APIs (DOM, timers)  │
│  • Network (fetch, WebSocket)  │
│  • Third-party libraries       │
│  • Browser storage             │
│  • Document properties         │
│  • Media devices               │
│  • Geolocation                 │
│  • Device sensors              │
└────────────────────────────────┘

8. What Does NOT Belong in useEffect

Anti-Pattern 1: Transforming Data for Rendering

// ❌ BAD: Effect to filter data
function TodoList({ todos, filter }) {
  const [filteredTodos, setFilteredTodos] = useState([]);
  
  useEffect(() => {
    setFilteredTodos(todos.filter(t => t.status === filter));
  }, [todos, filter]);
  // Problem: extra render — first render with stale data,
  //          then effect triggers re-render with filtered data
  
  return <ul>{filteredTodos.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
}

// ✅ GOOD: Calculate during render
function TodoList({ todos, filter }) {
  // Derived state — no effect needed
  const filteredTodos = todos.filter(t => t.status === filter);
  
  return <ul>{filteredTodos.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
}

// ✅ GOOD: useMemo if the calculation is expensive
function TodoList({ todos, filter }) {
  const filteredTodos = useMemo(
    () => todos.filter(t => t.status === filter),
    [todos, filter]
  );
  
  return <ul>{filteredTodos.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
}

Anti-Pattern 2: Responding to User Events

// ❌ BAD: Effect to handle form submission
function Form() {
  const [submitted, setSubmitted] = useState(false);
  
  useEffect(() => {
    if (submitted) {
      sendFormData(formData);
      setSubmitted(false);
    }
  }, [submitted, formData]);
  
  return <button onClick={() => setSubmitted(true)}>Submit</button>;
}

// ✅ GOOD: Just do it in the event handler
function Form() {
  function handleSubmit() {
    sendFormData(formData);
  }
  
  return <button onClick={handleSubmit}>Submit</button>;
}

Anti-Pattern 3: Initialising State from Props

// ❌ BAD: Effect to sync state from props
function EditProfile({ user }) {
  const [name, setName] = useState('');
  
  useEffect(() => {
    setName(user.name);
  }, [user.name]);
  // Problem: extra render, flash of empty state
  
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

// ✅ GOOD: Initialise state directly
function EditProfile({ user }) {
  const [name, setName] = useState(user.name);
  // If you need to reset when user changes:
  // Use key={user.id} on the parent
  
  return <input value={name} onChange={e => setName(e.target.value)} />;
}

Anti-Pattern 4: Notifying Parent Components

// ❌ BAD: Effect to call parent's callback
function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);
  
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange]);
  // Problem: runs after render, parent state update triggers ANOTHER render
  
  return <button onClick={() => setIsOn(!isOn)}>Toggle</button>;
}

// ✅ GOOD: Notify parent in the event handler
function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);
  
  function handleClick() {
    const next = !isOn;
    setIsOn(next);
    onChange(next); // Notify immediately, in same event
  }
  
  return <button onClick={handleClick}>Toggle</button>;
}

Anti-Pattern 5: Chaining Effects

// ❌ BAD: Effect chains
function ShippingForm({ country }) {
  const [city, setCity] = useState('');
  const [areas, setAreas] = useState([]);
  
  useEffect(() => {
    fetchCities(country).then(cities => setCity(cities[0]));
  }, [country]);
  
  useEffect(() => {
    if (city) {
      fetchAreas(city).then(setAreas);
    }
  }, [city]);
  // Problem: country changes → fetch cities → render → fetch areas → render
  //          Three renders for one change!
}

// ✅ GOOD: Fetch in event handler or combine
function ShippingForm({ country }) {
  const [city, setCity] = useState('');
  const [areas, setAreas] = useState([]);
  
  useEffect(() => {
    let cancelled = false;
    async function loadData() {
      const cities = await fetchCities(country);
      if (cancelled) return;
      const firstCity = cities[0];
      const cityAreas = await fetchAreas(firstCity);
      if (cancelled) return;
      setCity(firstCity);
      setAreas(cityAreas);
    }
    loadData();
    return () => { cancelled = true; };
  }, [country]);
}

Quick Decision Table

Code PatternUse useEffect?Instead Use
Filter/sort/transform dataCalculate during render or useMemo
Respond to button clickEvent handler
Initialise state from propsuseState(initialValue) or key prop
Notify parent of state changeCall callback in event handler
Chain dependent data fetchesSingle combined effect or event handler
Subscribe to external event
Fetch data on mount
Sync with browser API
Connect to WebSocket

9. Effects Are Closures Over Each Render

Every render creates its own "snapshot" of values, and effects close over that snapshot:

function Counter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    // This effect "captures" the count from THIS render
    setTimeout(() => {
      console.log(`Count was: ${count}`);
    }, 3000);
  }, [count]);
  
  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

// If you click 3 times quickly:
// After 3 seconds: "Count was: 0"
// After 3 seconds: "Count was: 1"
// After 3 seconds: "Count was: 2"
// Each effect saw its own render's count!

Visualising Closures

Render 1 (count = 0):
  ┌─────────────────────────────┐
  │ count = 0                   │
  │ effect: log count (→ 0)     │
  └─────────────────────────────┘

Render 2 (count = 1):
  ┌─────────────────────────────┐
  │ count = 1                   │
  │ effect: log count (→ 1)     │
  └─────────────────────────────┘

Render 3 (count = 2):
  ┌─────────────────────────────┐
  │ count = 2                   │
  │ effect: log count (→ 2)     │
  └─────────────────────────────┘

Each effect function is a DIFFERENT function,
closed over DIFFERENT values.

When Closures Cause Problems (Stale Closures)

// ❌ BUG: Stale closure
function Timer() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // Always logs 0!
      setCount(count + 1); // Always sets to 1!
    }, 1000);
    return () => clearInterval(id);
  }, []); // Empty deps = effect only runs once, closes over count = 0
}

// ✅ FIX 1: Use updater function
useEffect(() => {
  const id = setInterval(() => {
    setCount(prev => prev + 1); // Doesn't need to read count
  }, 1000);
  return () => clearInterval(id);
}, []);

// ✅ FIX 2: Use useRef for latest value
const countRef = useRef(count);
countRef.current = count; // Update ref every render

useEffect(() => {
  const id = setInterval(() => {
    console.log(countRef.current); // Always reads latest
  }, 1000);
  return () => clearInterval(id);
}, []);

The Mental Model for Closures

EACH RENDER IS A SNAPSHOT
========================

Think of each render as a photo:

  📸 Render 1: { count: 0, name: "Alice", effect: function() { ... count=0 ... } }
  📸 Render 2: { count: 1, name: "Alice", effect: function() { ... count=1 ... } }
  📸 Render 3: { count: 2, name: "Bob",   effect: function() { ... count=2 ... } }

Each effect function is part of its render's snapshot.
It can only see the values from that snapshot.

10. The Component Lifecycle Through Effects

While useEffect isn't a lifecycle method, it's useful to understand what happens at each phase:

Mount

function MyComponent() {
  useEffect(() => {
    // This runs after the FIRST paint
    // The component is now on screen
    console.log('Connected to external system');
    
    return () => {
      // This cleanup will run when:
      // 1. Dependencies change (before re-running setup)
      // 2. Component unmounts
      console.log('Disconnected from external system');
    };
  }, [/* dependencies */]);
}

Update (Dependency Change)

State/prop changes
  │
  ▼
React re-renders component
  │
  ▼
React commits DOM changes
  │
  ▼
Browser paints
  │
  ▼
React runs CLEANUP from previous effect  ← Old values
  │
  ▼
React runs SETUP of new effect           ← New values

Unmount

Component removed from tree
  │
  ▼
React runs CLEANUP from last effect
  │
  ▼
Component is gone

Full Example Showing All Phases

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    console.log(`🟢 Connecting to room: ${roomId}`);
    
    const ws = new WebSocket(`wss://chat.app/${roomId}`);
    
    ws.onmessage = (event) => {
      setMessages(prev => [...prev, JSON.parse(event.data)]);
    };
    
    ws.onerror = (error) => {
      console.error(`WebSocket error in ${roomId}:`, error);
    };
    
    return () => {
      console.log(`🔴 Disconnecting from room: ${roomId}`);
      ws.close();
    };
  }, [roomId]);
  
  return (
    <div>
      <h2>Room: {roomId}</h2>
      {messages.map((msg, i) => (
        <p key={i}>{msg.text}</p>
      ))}
    </div>
  );
}

// User navigates to room "general":
// 🟢 Connecting to room: general

// User switches to room "random":
// 🔴 Disconnecting from room: general
// 🟢 Connecting to room: random

// User leaves the page:
// 🔴 Disconnecting from room: random

11. useEffect vs useLayoutEffect

Timing Difference

                    useLayoutEffect              useEffect
                    ↓                            ↓
DOM Update → [run layout effects] → Paint → [run effects]
             ↑ synchronous,                  ↑ asynchronous,
               blocks paint                    non-blocking

When to Use Each

// useEffect — DEFAULT CHOICE (95% of cases)
// Runs after paint. Non-blocking. Better performance.
useEffect(() => {
  document.title = `${count} items`;
}, [count]);

// useLayoutEffect — DOM measurements ONLY
// Runs before paint. Blocking. Use only when you need to
// measure and adjust before the user sees anything.
useLayoutEffect(() => {
  const { height } = tooltipRef.current.getBoundingClientRect();
  setTooltipHeight(height);
}, []);

Comparison Table

FeatureuseEffectuseLayoutEffect
TimingAfter browser paintBefore browser paint
BlockingNoYes
Visual flickerPossibleNever
PerformanceBetterWorse (blocks paint)
Use forData fetching, subscriptions, loggingDOM measurement, scroll position
SSRWorks normallyWarning in SSR (no DOM)
Default?✅ YesOnly when needed

The Classic Tooltip Example

// ❌ useEffect — tooltip appears at wrong position, then jumps
function Tooltip({ targetRef, text }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const tooltipRef = useRef(null);
  
  useEffect(() => {
    const targetRect = targetRef.current.getBoundingClientRect();
    const tooltipRect = tooltipRef.current.getBoundingClientRect();
    
    // Calculate position to center tooltip above target
    setPosition({
      top: targetRect.top - tooltipRect.height - 8,
      left: targetRect.left + (targetRect.width - tooltipRect.width) / 2,
    });
  }, [targetRef]);
  // User sees: tooltip at (0,0), then it jumps to correct position
  
  return (
    <div ref={tooltipRef} style={{ position: 'fixed', ...position }}>
      {text}
    </div>
  );
}

// ✅ useLayoutEffect — tooltip appears at correct position immediately
function Tooltip({ targetRef, text }) {
  const [position, setPosition] = useState({ top: 0, left: 0 });
  const tooltipRef = useRef(null);
  
  useLayoutEffect(() => {
    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]);
  // User sees: tooltip at correct position from the start
  
  return (
    <div ref={tooltipRef} style={{ position: 'fixed', ...position }}>
      {text}
    </div>
  );
}

12. Effects in Strict Mode

In development with React 18+, Strict Mode double-invokes effects:

Component mounts
  │
  ▼
Setup runs ← first invocation
  │
  ▼
Cleanup runs ← React immediately unmounts
  │
  ▼
Setup runs ← React re-mounts (second invocation)

Why?

This helps catch effects that don't clean up properly:

// ❌ BUG: No cleanup — Strict Mode reveals the problem
useEffect(() => {
  window.addEventListener('resize', handleResize);
  // Forgot cleanup! In production, this leak is silent.
  // In Strict Mode: TWO listeners attached!
}, []);

// ✅ FIXED: Proper cleanup — works correctly in Strict Mode
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

Strict Mode Behaviour with Fetch

// ❌ PROBLEM: Double fetch in Strict Mode
useEffect(() => {
  fetch(`/api/user/${userId}`)
    .then(res => res.json())
    .then(setUser);
}, [userId]);
// In Strict Mode: fetches TWICE. First result ignored (good!),
// but still wastes a network request.

// ✅ SOLUTION: AbortController
useEffect(() => {
  const controller = new AbortController();
  
  fetch(`/api/user/${userId}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });
  
  return () => controller.abort();
}, [userId]);
// In Strict Mode: first request is aborted, second succeeds. Clean!

Rules for Strict Mode Compatibility

  1. Every setup must have a matching cleanup
  2. Running setup → cleanup → setup must produce the same result as just setup
  3. Cleanup must undo what setup did
// Test: Does setup → cleanup → setup produce correct state?

// ✅ YES — WebSocket
// setup: connect to "general"
// cleanup: disconnect from "general"
// setup: connect to "general"
// Result: connected to "general" ✓

// ❌ NO — Counter without cleanup
let connectionCount = 0;
// setup: connectionCount++ (→ 1)
// cleanup: (nothing)
// setup: connectionCount++ (→ 2)
// Result: connectionCount is 2, should be 1 ✗

13. The "You Might Not Need an Effect" Principle

The React team's official guidance: before writing useEffect, ask if you can avoid it entirely.

Decision Flowchart

Do I need to run some code?
  │
  ├─ Is it triggered by a specific user interaction?
  │   └─ YES → Use an event handler, not useEffect
  │
  ├─ Am I transforming data for rendering?
  │   └─ YES → Calculate during render (or useMemo)
  │
  ├─ Am I initialising state from props?
  │   └─ YES → Pass to useState(initialValue) or use key
  │
  ├─ Am I notifying a parent component?
  │   └─ YES → Call parent's callback in the event handler
  │
  ├─ Am I caching an expensive calculation?
  │   └─ YES → Use useMemo
  │
  ├─ Am I resetting state when props change?
  │   └─ YES → Use key={prop} on the component
  │
  └─ Am I syncing with an external system?
      └─ YES → ✅ Use useEffect!

The Refactoring Checklist

Before writing useEffect, try these alternatives in order:

PriorityAlternativeWhen
1Calculate during renderDerived/transformed data
2useMemoExpensive calculations
3Event handlerUser-triggered actions
4key propReset component state
5useState initialiserInitialise from props
6useEffectExternal system synchronisation

14. Real-World Mental Model: Chat Room Connection

Let's build a complete mental model with a real-world example:

function ChatRoom({ roomId, serverUrl }) {
  const [messages, setMessages] = useState([]);
  
  useEffect(() => {
    // ─── SETUP: Start synchronising ────────────────────────
    // "Keep a WebSocket connection open to the current room"
    
    const connection = new WebSocket(`${serverUrl}/rooms/${roomId}`);
    
    connection.onopen = () => {
      console.log(`Connected to ${roomId}`);
    };
    
    connection.onmessage = (event) => {
      const message = JSON.parse(event.data);
      setMessages(prev => [...prev, message]);
    };
    
    connection.onerror = (error) => {
      console.error('Connection error:', error);
    };
    
    // ─── CLEANUP: Stop synchronising ───────────────────────
    // "Disconnect from the room when we need to re-sync or unmount"
    
    return () => {
      connection.close();
      console.log(`Disconnected from ${roomId}`);
    };
    
    // ─── DEPENDENCIES: When to re-synchronise ──────────────
    // "Re-sync whenever the room or server changes"
  }, [roomId, serverUrl]);
  
  return (
    <div>
      <h2>Room: {roomId}</h2>
      <ul>
        {messages.map((msg, i) => (
          <li key={i}>
            <strong>{msg.author}:</strong> {msg.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

What Happens at Each Phase

User opens app, roomId = "general", serverUrl = "wss://chat.app"
  │
  ├─ Component renders with roomId="general"
  ├─ Browser paints the UI
  └─ Effect: WebSocket connects to wss://chat.app/rooms/general
     └─ Messages start arriving

User changes room to "random"
  │
  ├─ Component re-renders with roomId="random"
  ├─ Browser paints the updated UI (shows "Room: random")
  ├─ Cleanup: WebSocket disconnects from "general"
  └─ Effect: WebSocket connects to wss://chat.app/rooms/random
     └─ New room's messages start arriving

Admin changes serverUrl to "wss://backup.chat.app"
  │
  ├─ Component re-renders with new serverUrl
  ├─ Browser paints
  ├─ Cleanup: WebSocket disconnects from wss://chat.app/rooms/random
  └─ Effect: WebSocket connects to wss://backup.chat.app/rooms/random

User navigates away
  │
  ├─ Component unmounts
  └─ Cleanup: WebSocket disconnects from backup server

The Synchronisation Summary

┌──────────────────────────────────────────────────────┐
│                  Mental Model                        │
│                                                      │
│  useEffect = "Keep X in sync with Y"                │
│                                                      │
│  X = external system (WebSocket, DOM, timer, etc.)   │
│  Y = component's current props and state             │
│                                                      │
│  Setup   = "Start keeping X in sync with Y"          │
│  Cleanup = "Stop keeping X in sync with Y"           │
│  Deps    = "Re-sync when these parts of Y change"    │
│                                                      │
│  React handles:                                      │
│  - When to call setup and cleanup                    │
│  - Comparing deps to decide if re-sync is needed     │
│  - Ensuring cleanup runs before re-sync              │
│  - Ensuring cleanup runs on unmount                  │
│                                                      │
│  You handle:                                         │
│  - What X to sync with                               │
│  - How to start syncing (setup)                      │
│  - How to stop syncing (cleanup)                     │
│  - What Y values the sync depends on (deps)          │
└──────────────────────────────────────────────────────┘

15. Key Takeaways

  1. useEffect is synchronisation, not lifecycle. Don't think "run on mount" — think "keep this external system in sync."

  2. Every effect has three parts: setup (start syncing), cleanup (stop syncing), and dependencies (when to re-sync).

  3. Effects run after browser paint. The user sees the updated UI before effects execute.

  4. Effects are closures. Each render's effect captures that render's values. This is by design, not a bug.

  5. Most code doesn't need useEffect. Calculate during render, respond to events in handlers, and only use effects for external system synchronisation.

  6. Strict Mode double-invokes effects to catch missing cleanups. Make sure setup → cleanup → setup produces the correct state.

  7. useLayoutEffect blocks paint. Only use it when you need DOM measurements before the user sees anything.

  8. Event handlers vs effects: User did something → event handler. Component is displaying → effect.


Explain-It Challenge

  1. The Sync Analogy: Explain useEffect to someone by comparing it to a smart thermostat. The thermostat "synchronises" the room temperature with a target setting. What is the setup? What is the cleanup? What are the dependencies?

  2. Why Not Lifecycle?: Your colleague says "useEffect with an empty dependency array is the same as componentDidMount." Explain why this mental model is subtly wrong and what problems it leads to.

  3. The Photo Album: Explain how effects are closures over each render using the analogy of taking a photo at each render. Each photo captures the exact moment, and each effect only "sees" its own photo. How does this help explain the stale closure bug?


Navigation: ← Overview · Next → Dependency Array Behaviour