Episode 2 — React Frontend Architecture NextJS / 2.8 — useEffect Deep Dive
2.8.a — What useEffect Really Does
In one sentence:
useEffectis 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
- The Mental Model Shift
- What Is an "Effect"?
- The Synchronisation Mental Model
- How useEffect Actually Executes
- The Render-and-Commit Pipeline
- useEffect vs Event Handlers
- What Belongs in useEffect
- What Does NOT Belong in useEffect
- Effects Are Closures Over Each Render
- The Component Lifecycle Through Effects
- useEffect vs useLayoutEffect
- Effects in Strict Mode
- The "You Might Not Need an Effect" Principle
- Real-World Mental Model: Chat Room Connection
- 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 Thinking | Synchronisation Thinking |
|---|---|
| "When does this run?" | "What am I syncing with?" |
| Mount, update, unmount | Start syncing, stop syncing |
| Three separate events | One continuous connection |
| "Does it run on mount?" | "Does it sync with the right value?" |
| Easy to miss edge cases | Naturally 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?"
| Scenario | Cause | Where to Put It |
|---|---|---|
| User clicks "Send" | User action | Event handler |
| Component needs to fetch user data when it appears | Rendering | useEffect |
| User types in search box | User action | Event handler (then maybe useEffect for debounce) |
| Component needs to connect to WebSocket | Rendering | useEffect |
| User submits a form | User action | Event handler |
| Document title should match current page | Rendering | useEffect |
| Analytics event when page loads | Rendering | useEffect |
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?
- Non-blocking: Effects don't delay the visual update
- Better UX: Users see the new UI immediately
- 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
| Aspect | Event Handler | useEffect |
|---|---|---|
| Triggered by | Specific user action | Component rendering |
| Timing | Immediately on interaction | After browser paint |
| Cause | "The user clicked Buy" | "The component is showing productId=5" |
| Examples | Send message, navigate, submit form | Fetch data, connect WebSocket, sync title |
| Can set state? | Yes | Yes (but think twice) |
| Common mistake | N/A | Using 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 Pattern | Use useEffect? | Instead Use |
|---|---|---|
| Filter/sort/transform data | ❌ | Calculate during render or useMemo |
| Respond to button click | ❌ | Event handler |
| Initialise state from props | ❌ | useState(initialValue) or key prop |
| Notify parent of state change | ❌ | Call callback in event handler |
| Chain dependent data fetches | ❌ | Single 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
| Feature | useEffect | useLayoutEffect |
|---|---|---|
| Timing | After browser paint | Before browser paint |
| Blocking | No | Yes |
| Visual flicker | Possible | Never |
| Performance | Better | Worse (blocks paint) |
| Use for | Data fetching, subscriptions, logging | DOM measurement, scroll position |
| SSR | Works normally | Warning in SSR (no DOM) |
| Default? | ✅ Yes | Only 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
- Every setup must have a matching cleanup
- Running setup → cleanup → setup must produce the same result as just setup
- 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:
| Priority | Alternative | When |
|---|---|---|
| 1 | Calculate during render | Derived/transformed data |
| 2 | useMemo | Expensive calculations |
| 3 | Event handler | User-triggered actions |
| 4 | key prop | Reset component state |
| 5 | useState initialiser | Initialise from props |
| 6 | useEffect | External 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
-
useEffect is synchronisation, not lifecycle. Don't think "run on mount" — think "keep this external system in sync."
-
Every effect has three parts: setup (start syncing), cleanup (stop syncing), and dependencies (when to re-sync).
-
Effects run after browser paint. The user sees the updated UI before effects execute.
-
Effects are closures. Each render's effect captures that render's values. This is by design, not a bug.
-
Most code doesn't need useEffect. Calculate during render, respond to events in handlers, and only use effects for external system synchronisation.
-
Strict Mode double-invokes effects to catch missing cleanups. Make sure setup → cleanup → setup produces the correct state.
-
useLayoutEffect blocks paint. Only use it when you need DOM measurements before the user sees anything.
-
Event handlers vs effects: User did something → event handler. Component is displaying → effect.
Explain-It Challenge
-
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?
-
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.
-
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