React Life Cycle (Hooks era)
Component lifecycle in React 18+/19 with function components & hooks.
① Hooks Lifecycle Diagram — Function component (React 18+)
A visual map of the function component lifecycle in the hooks era. Arrows show the order React runs each phase.
useState(init)useReducer(reducer, init)useRef(init)useMemo / useCallback→ returns JSX
Hooks run in the same order
useMemo / useCallback (deps)→ React diffs the Virtual DOM
useLayoutEffect(fn, [])Sync, before paint — measure DOM
useLayoutEffect(fn, [deps])return () => ...useEffect(fn, [])Async, after paint — fetch / subscribe / timer
useEffect(fn, [deps])When deps change value
return () => ...In reverse order — child first, parent last
Concurrent rendering (React 18+)
The render phase can be paused / aborted / re-run by React. Therefore:
- Render must be pure — no side effects (don’t
fetchorsetStatein the body). - Only the commit phase is guaranteed to run once and synchronously.
useTransition/useDeferredValuemark an update as interruptible.
② Class Lifecycle Diagram — Class component (legacy)
A static diagram for class components using the same 3-column style so you can compare directly with the hooks diagram above.
constructor(props)Init state, bind methods
static getDerivedStateFromPropsSync state ⇐ props
componentWillUnmount()Cancel timers, listeners, subscriptions
static getDerivedStateFromPropsBefore the first render
shouldComponentUpdate(np, ns)Return false to skip render
Component removed from the DOM
render()Output JSX
render()Re-render with new state/props
componentDidMount()After DOM is mounted — fetch / subscribe
getSnapshotBeforeUpdateMeasure DOM before commit
componentDidUpdate(pp, ps)After commit — side effects based on changes
React Lifecycle Methods diagram
End-to-end example: Counter + Timer
The component below logs each phase to the console — run it to see the actual execution order between render, commit, useLayoutEffect and useEffect.
tsxfunction Counter({ step }: { step: number }) { const [count, setCount] = useState(0); console.log('1. Render', { count, step }); useLayoutEffect(() => { console.log('3. useLayoutEffect (sync, before paint)'); return () => console.log(' ↳ cleanup useLayoutEffect'); }, [count]); useEffect(() => { console.log('4. useEffect (async, after paint)'); const id = setInterval(() => setCount((c) => c + step), 1000); return () => { console.log(' ↳ cleanup useEffect (interval)'); clearInterval(id); }; }, [step]); // 2. React commits the DOM here (between render and effect) return <p>Count: {count}</p>; }
1 → 2 (commit) → 3 → paint → 4. When step changes: 1 → 2 → old useEffect cleanup → new 4 (useLayoutEffect does NOT re-run because its deps are [count]). On unmount: both cleanups run in reverse order.Deps array — 3 forms & consequences
| Syntax | When it runs | Use for | Common bug |
|---|---|---|---|
useEffect(fn)no deps argument | After every render | Almost never | Infinite loop if you setState inside |
useEffect(fn, []) | Once on mount, cleanup on unmount | Global subscribe, init external libs | Stale closure — reads old state/props |
useEffect(fn, [a, b]) | When a or b changes (Object.is compare) | Sync with a specific prop / state | Missing deps → ESLint react-hooks/exhaustive-deps |
useCallback, or move it outside the component if it’s pure.Common Pitfalls (5 frequent mistakes)
① Infinite loop
tsx// ❌ setState every render → re-render → setState → ... useEffect(() => { setData({ ...data, ready: true }); }); // ✅ Add a deps array, or use a functional update useEffect(() => { setData((d) => ({ ...d, ready: true })); }, []);
② Stale closure
tsx// ❌ count is always 0 — the closure captures the value at mount useEffect(() => { const id = setInterval(() => setCount(count + 1), 1000); return () => clearInterval(id); }, []); // ✅ functional update does not depend on the closure useEffect(() => { const id = setInterval(() => setCount((c) => c + 1), 1000); return () => clearInterval(id); }, []);
③ Forgetting cleanup
tsx// ❌ Listener duplicated after every update / strict mode → memory leak useEffect(() => { window.addEventListener('resize', onResize); }, []); // ✅ Always return a cleanup useEffect(() => { window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []);
④ Async function directly
tsx// ❌ useEffect doesn't accept a Promise — a returned Promise is ignored, cleanup won't run useEffect(async () => { const data = await fetch(url); }, [url]); // ✅ Declare async inside + a cancel flag useEffect(() => { let cancelled = false; (async () => { const data = await (await fetch(url)).json(); if (!cancelled) setData(data); })(); return () => { cancelled = true; }; }, [url]);
⑤ Using useEffect when you don’t need it (You Might Not Need an Effect)
tsx// ❌ Deriving via an effect — extra render const [fullName, setFullName] = useState(''); useEffect(() => { setFullName(`${first} ${last}`); }, [first, last]); // ✅ Compute directly during render const fullName = `${first} ${last}`; // ❌ Reacting to an event handler inside an effect useEffect(() => { if (submitted) postData(form); }, [submitted]); // ✅ Put the logic in the event itself function handleSubmit() { postData(form); }
useLayoutEffect — when do you really need it?
useEffect runs after the browser paints, so the user may briefly see an "intermediate" frame before the effect updates the DOM. useLayoutEffect runs synchronously before paint → avoids flicker but blocks render.
tsx// Tooltip needs to measure its size then position itself — useEffect would flicker function Tooltip({ targetRect, children }: Props) { const ref = useRef<HTMLDivElement>(null); const [top, setTop] = useState(0); useLayoutEffect(() => { const { height } = ref.current!.getBoundingClientRect(); setTop(targetRect.top - height - 8); // sync before paint → 0 flicker }, [targetRect]); return <div ref={ref} style={{ top }}>{children}</div>; }
useEffect by default. Switch to useLayoutEffect only when the user sees flicker because you need to measure the DOM then mutate it immediately.useLayoutEffect vs useEffect — comparison table
| Criteria | useEffect | useLayoutEffect |
|---|---|---|
| Timing | After paint (async) | Before paint (sync) |
| Block UI? | No | Yes — slow work janks |
| SSR | OK | Warning (runs on client only) |
| Use case | Fetch, subscribe, log, timer | Measure DOM, scroll position, tooltip |
Strict Mode double-invoke (React 18+)
In dev, React deliberately mounts → unmounts → mounts again once to force you to write cleanup correctly. Production is NOT affected.
tsxuseEffect(() => { console.log('connect'); const conn = chat.connect(); return () => { console.log('disconnect'); conn.disconnect(); }; }, []); // Dev console: // connect // disconnect ← React simulated unmount // connect ← mount again // → without cleanup, you'd have 2 connections alive at once
FAQ
Why does useEffect run twice right after I mount?
That’s Strict Mode in dev. Turning Strict Mode off (removing <StrictMode> in the layout) stops it, but the right fix is to write complete cleanup.
useEffect or event handler — when to use which?
If the logic is reacting to a user action (click, submit) → event handler. If it’s syncing with an external system (server, browser API, subscription) → useEffect.
When should you NOT use useEffect?
- Computing a value from other state/props → compute it directly during render.
- Resetting state when a prop changes → use
keyon the component. - Fetching on mount → use
use()+ Suspense / a framework data layer (Next.js, React Query). - Reacting to an event → put it in the handler.
What’s the execution order between parent & child?
Render: parent first, child after. Effects & cleanups: child first, parent last (bottom-up). useLayoutEffect follows the same rule, only running synchronously before paint.
React 19 — new lifecycle-related hooks
use(promise)— read a Promise/Context during render, integrated with Suspense. Replaces manyuseEffect + setStatefetch cases.useActionState(action, initial)— manage state for an async form action, with pending.useFormStatus()— read the pending status of the parent form inside a submit button.useOptimistic(state, reducer)— optimistic UI while an action is running.- Auto memoization (React Compiler) — will reduce the need for
useMemo/useCallback.
useEffect — pattern cheatsheet
tsxuseEffect(() => { // Mount + whenever deps change (Update) const id = setInterval(tick, 1000); return () => clearInterval(id); // Cleanup: runs on Unmount OR before the next effect }, [deps]);
General render flow
Mount:
1. Initialize state (useState/useReducer)
2. Render JSX
3. Commit to the DOM
4. useLayoutEffect (sync)
5. Paint
6. useEffect (async)
Update:
1. State/props change → re-render
2. Diff & commit
3. Old effect cleanup → new useEffect (if deps changed)
Unmount:
1. Clean up all effects (child first, parent last)Common hooks
tsxuseState(initial) // simple state useReducer(reducer, init) // complex state / many actions useRef(initial) // value box, no re-render useMemo(fn, [deps]) // cache an expensive compute useCallback(fn, [deps]) // cache a function reference useTransition() // mark a non-urgent update useDeferredValue(value) // defer a value until idle useId() // stable id for a11y useSyncExternalStore() // subscribe to a store outside React
Class → Hooks mapping
| Class method | Hook equivalent | Notes |
|---|---|---|
componentDidMount | useEffect(fn, []) | Strict Mode dev runs it twice |
componentDidUpdate | useEffect(fn, [deps]) | Declare the right deps |
componentWillUnmount | useEffect(() => () => {...}, []) | Return a cleanup function |
shouldComponentUpdate | React.memo + useMemo | Or let the React Compiler handle it |
getDerivedStateFromProps | Compute directly during render | Or reset with key |
getSnapshotBeforeUpdate | useLayoutEffect | Measure DOM before paint |
componentDidCatch | Error Boundary (still needs a class) | Or react-error-boundary |
this.state / setState | useState / useReducer | setState merges objects → useState replaces |
this.refs / createRef | useRef | Mutating .current does not re-render |