06 — useEffect & Lifecycle
The Effect Lifecycle
effect_lifecycle.js
import { useEffect } from 'react'
// useEffect runs AFTER the browser paints (asynchronous — not during render).
// The dependency array controls WHICH phases of the lifecycle trigger the effect:
// ┌─────────────────────────────────────────────────────────────────────┐
// │ deps array │ when the effect runs │
// ├───────────────┼─────────────────────────────────────────────────────┤
// │ [a, b] │ on mount + when a or b changes (Object.is compare) │
// │ [] │ on mount ONLY (never on update) │
// │ (omitted) │ after EVERY render (mount + every update) │
// └─────────────────────────────────────────────────────────────────────┘
function DataViewer({ resourceId }) {
const [data, setData] = useState(null)
useEffect(() => {
// Effect body runs AFTER paint
fetchData(resourceId).then(setData)
// Optional cleanup: runs BEFORE the next effect AND on unmount
return () => {
console.log('cleanup: resourceId changed or component unmounting')
}
}, [resourceId]) // runs on mount + when resourceId changes
// LIFECYCLE SEQUENCE for resourceId changing from 1 → 2:
// 1. Render with resourceId=2 (new state/props trigger re-render)
// 2. Browser paints with new resourceId
// 3. Cleanup from PREVIOUS effect runs (resourceId was 1)
// 4. NEW effect body runs (resourceId is now 2)
// → cleanup → effect → cleanup → effect → ... → final cleanup on unmount
}
Dependency Array: Object.is Comparison
deps_comparison.js
// React compares each dep with Object.is(prevDep, nextDep).
// If ANY dep fails Object.is, the effect re-runs.
// Object.is works for primitives (strings, numbers, booleans):
useEffect(() => { /* runs when id changes */ }, [id]) // ✓ id=1 vs id=2 → re-runs
// Object.is FAILS for new object/array/function references:
useEffect(() => { /* runs every render! */ }, [{ page: 1 }])
// { page: 1 } !== { page: 1 } — new object literal every render → always different
useEffect(() => { /* runs every render! */ }, [() => doSomething()])
// New function reference every render → always different
// THIS IS WHY useMemo/useCallback EXIST:
const config = useMemo(() => ({ page: 1 }), []) // stable reference
const handler = useCallback(() => doSomething(), [doSomething]) // stable reference
useEffect(() => { /* runs only when config or handler reference changes */ }, [config, handler])
// Now the effect only re-runs when the memoized values actually change.
Async Race Conditions
race_condition.js
// ANTI-PATTERN: no cancellation — stale responses overwrite fresh data
function BadProfile({ userId }) {
const [profile, setProfile] = useState(null)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setProfile(data))
}, [userId])
// BUG: user switches from userId=1 to userId=2 quickly.
// Request 2 fires, then request 1 (slow) resolves LAST → overwrites
// profile with userId=1's data while userId=2 is the active prop.
return <div>{profile?.name}</div>
}
// PRODUCTION: AbortController for cancellation
function GoodProfile({ userId }) {
const [profile, setProfile] = useState(null)
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setProfile(data))
.catch(err => {
if (err.name !== 'AbortError') throw err // re-throw real errors
})
return () => controller.abort() // cancels the in-flight request on cleanup
}, [userId])
// When userId changes: cleanup aborts the old request → no stale overwrite.
return <div>{profile?.name}</div>
}
// ALTERNATIVE: stale flag (if you can't use AbortController)
useEffect(() => {
let isStale = false
fetch(`/api/users/${userId}`).then(res => res.json()).then(data => {
if (!isStale) setProfile(data) // only set if this effect is still current
})
return () => { isStale = true }
}, [userId])
Debouncing via useEffect
debounce_effect.js
// The canonical debounce pattern: useEffect + setTimeout + cleanup
function SearchBox({ onSearch }) {
const [query, setQuery] = useState('')
useEffect(() => {
if (query === '') return
const timeoutId = setTimeout(() => onSearch(query), 300)
// Cleanup runs BEFORE the next effect: clears the timer if query changes
// within 300ms → the API call only fires 300ms after the user STOPS typing.
return () => clearTimeout(timeoutId)
}, [query, onSearch])
return <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search…" />
}
// SEQUENCE for typing "abc" (3 keystrokes within 300ms):
// keystroke "a" → effect → setTimeout(300ms)
// keystroke "b" → cleanup (clears timeout) → effect → setTimeout(300ms)
// keystroke "c" → cleanup (clears timeout) → effect → setTimeout(300ms)
// 300ms passes → onSearch("abc") fires ONCE
Production Data Fetching Pattern
production_fetch.js
function useFetch(url) {
const [state, setState] = useState({ data: null, loading: true, error: null })
useEffect(() => {
if (!url) return
const controller = new AbortController()
setState(prev => ({ ...prev, loading: true, error: null }))
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
})
.then(data => setState({ data, loading: false, error: null }))
.catch(err => {
if (err.name === 'AbortError') return // ignore cancellation
setState(prev => ({ ...prev, loading: false, error: err.message }))
})
return () => controller.abort()
}, [url])
return state
}
// Usage: const { data, loading, error } = useFetch('/api/users/1')
// Handles: loading state, error state, cancellation on URL change,
// and avoids the stale-response race condition.
Anti-Pattern: Effects for Derived State
anti_pattern_derived.js
// ANTI-PATTERN: using an effect to compute a value from props/state
function BadProductList({ products, filter }) {
const [filtered, setFiltered] = useState(products)
useEffect(() => {
setFiltered(products.filter(p => p.category === filter))
}, [products, filter])
// This causes an EXTRA render: render → effect → setState → re-render.
// The user sees a flash of the unfiltered list before the effect runs.
return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
// PRODUCTION: compute during render (React handles it efficiently)
function GoodProductList({ products, filter }) {
const filtered = useMemo(
() => products.filter(p => p.category === filter),
[products, filter]
)
// No extra render. useMemo avoids recomputing on unrelated re-renders.
// For truly simple derivations, even useMemo is optional — just:
// const filtered = products.filter(p => p.category === filter)
return <ul>{filtered.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
💡 Tips & Tricks
tips.js
// [Idiom] Think of useEffect as "sync this external thing with these props/state,"
// not as a lifecycle method. It's NOT componentDidMount + componentDidUpdate.
// It's: "after render, make sure X is in sync with [deps]."
// [Debug] If your effect runs more often than expected, check for inline
// objects/functions in the deps array. {} !== {} every render → infinite effect.
// [Performance] For expensive derivations from props/state, use useMemo during
// render — NOT useEffect + setState. Effects for derived state cause an extra
// render and a flash of stale data.
// [Idiom] The cleanup function is not optional for effects that create
// subscriptions, timers, or network requests. Always return a cleanup that
// tears down what the effect set up.
// [Debug] eslint-plugin-react-hooks' exhaustive-deps rule catches missing
// dependencies. Don't silence it with eslint-disable — fix the underlying issue
// (usually by memoizing the dep with useCallback/useMemo, or restructuring).
⚠️ Edge Cases & Gotchas
edge_cases.js
// [Gotcha] useEffect runs AFTER paint. If you need to measure/modify DOM BEFORE
// paint (to avoid flicker), use useLayoutEffect instead. It runs synchronously
// after DOM mutations but before the browser paints.
// [Gotcha] Empty deps [] means "run once on mount" — but the closure inside
// captures values from the FIRST render forever. If you reference state/props
// inside, they'll be stale. Use refs or functional updates to access fresh values.
// [Gotcha] The cleanup function runs on unmount AND before the next effect.
// It does NOT only run on unmount. If your deps change 5 times, cleanup runs
// 5 times (before each new effect) plus once on unmount.
// [Gotcha] StrictMode double-invokes effects in dev: mount → unmount → mount.
// An effect that opens a WebSocket without cleanup → TWO connections in dev.
// Always implement cleanup, even if you think the effect runs "once."
// [Gotcha] Calling setState inside an effect that depends on that same state
// creates an infinite loop: effect → setState → re-render → effect → ...
// Break the cycle by removing the state from deps, or use a ref instead.
// [Safety] Never call a setter from a different component's effect. React throws
// "Cannot update a component while rendering a different component." If two
// components need to share state, lift it up to a common parent or use context.
🧠 Spot the Bug
A component fetches data on mount but the response seems to never arrive — the loading spinner stays forever, even though the network tab shows the response:
spot_the_bug.js
function Profile({ userId }) {
const [profile, setProfile] = useState(null)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setProfile)
}, []) // ← empty deps
if (!profile) return <Spinner />
return <div>{profile.name}</div>
}
Answer
The dependency array is [] (empty), meaning the effect runs only on mount and captures userId from the first render. If userId changes, the effect never re-runs — the component shows stale data (or keeps loading if the first fetch hasn't resolved yet for a different user). The empty deps also cause a stale closure: even if the fetch succeeds, it fetches the original userId, not the current one.
Fix: include userId in the dependency array:
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setProfile)
.catch(err => { if (err.name !== 'AbortError') throw err })
return () => controller.abort()
}, [userId]) // re-runs when userId changes, with cancellation
Key Takeaways
key_takeaways.js
// 1. useEffect runs AFTER paint. deps=[a,b] → runs on mount + when a or b
// changes (Object.is comparison). deps=[] → mount only. deps omitted → every render.
// 2. Cleanup runs BEFORE the next effect AND on unmount — not just unmount.
// Always clean up subscriptions, timers, and network requests.
// 3. Deps compare with Object.is — inline objects/functions create new refs
// every render → effect runs every time. Use useMemo/useCallback to stabilize.
// 4. Async effects need cancellation (AbortController or stale flag) to prevent
// race conditions where a stale response overwrites fresh data.
// 5. Don't use effects for derived state — compute during render (useMemo or
// inline). Effects for derived state cause an extra render + flash of stale data.
// 6. Empty deps [] captures stale values forever. If you reference props/state
// inside, include them in deps, or use refs/functional updates for fresh access.