useEffect & Side Effects
useEffect & Side Effects
A React component should be a pure function: same props + state in, same JSX out. But real applications need to interact with the outside world — fetch data, start timers, listen to DOM events. These are side effects because they affect things outside the component's return value.
useEffect is React's escape hatch for side effects. It runs after the component renders and after React has updated the DOM.
Why After Render?
React renders your component, paints the DOM, then runs effects. This order matters because:
- The user sees the UI immediately, not waiting for data to load
- Effects can reference real DOM nodes (they exist after render)
- Server components can render without any effects (SSR safety)
Basic useEffect — Syncing with External State
import { useState, useEffect } from 'react'
function DocumentTitle({ title }: { title: string }) {
useEffect(() => {
document.title = title // side effect: modifying the browser tab
}, [title]) // re-run whenever title changes
return null
}The Dependency Array — Three Patterns
// Pattern 1: [] — run once on mount, cleanup on unmount
useEffect(() => {
const id = setInterval(tick, 1000)
return () => clearInterval(id) // cleanup: cancel the interval
}, [])
// Pattern 2: [dep] — run when dep changes (including first mount)
useEffect(() => {
fetchUser(userId)
}, [userId]) // re-fetch whenever userId changes
// Pattern 3: No array — run after EVERY render (rarely needed)
useEffect(() => {
console.log('rendered with count:', count)
})Data Fetching — The Production Pattern
function CourseDetail({ courseId }: { courseId: string }) {
const [course, setCourse] = useState<Course | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false // prevents stale state on unmount or dep change
async function load() {
try {
setLoading(true)
const res = await fetch(`/api/courses/${courseId}`)
const data = await res.json()
if (!cancelled) { // only update state if still mounted
setCourse(data)
setError(null)
}
} catch (err) {
if (!cancelled) setError('Failed to load course.')
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true } // cleanup: mark as cancelled
}, [courseId]) // re-fetch when courseId changes
if (loading) return <Spinner />
if (error) return <ErrorMessage message={error} />
if (!course) return null
return <CourseView course={course} />
}The cancelled flag prevents a race condition: if courseId changes while a fetch is in flight, the cleanup sets cancelled = true. When the old fetch completes, it checks cancelled before updating state — preventing stale data from overwriting fresh data.
Cleanup — Preventing Memory Leaks
// Event listener cleanup
useEffect(() => {
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
// Subscription cleanup
useEffect(() => {
const subscription = store.subscribe(handleUpdate)
return () => subscription.unsubscribe()
}, [])
// Timer cleanup
useEffect(() => {
const id = setTimeout(() => setShowNotice(true), 5000)
return () => clearTimeout(id)
}, [])The cleanup function runs:
- Before the effect runs again (when deps change)
- When the component unmounts from the DOM
Without cleanup, event listeners and subscriptions accumulate every time the component mounts — a memory leak that crashes long-running apps.