Component Lifecycle
Every React component passes through three phases during its lifetime: it mounts (appears in the DOM), it updates (re-renders when state or props change), and it unmounts (is removed from the DOM). Understanding these phases — and which hooks map to each — is foundational for writing correct side effects, subscriptions, and cleanup logic.
The Three Phases
Mount — the component is created and inserted into the DOM for the first time. This is when initial data fetching, subscriptions, and DOM measurements typically happen.
Update — the component re-renders because its state or props changed. React re-runs your function, computes a diff, and patches the DOM.
Unmount — the component is removed from the DOM. This is when you must clean up subscriptions, timers, and event listeners to prevent memory leaks.
Phase 1 — Mount
Pass an empty dependency array [] to useEffect to run code exactly once, after the component first appears in the DOM:
import { useState, useEffect } from 'react'
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
// Runs ONCE after the component mounts — equivalent to componentDidMount
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data))
}, []) // ← empty array = run on mount only
if (!user) return <p>Loading…</p>
return <h1>{user.name}</h1>
}Phase 2 — Update
To run an effect whenever specific values change, list them as dependencies. React compares each dependency to its previous value using Object.is and re-runs the effect only when at least one has changed:
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
// Runs on mount AND whenever userId changes — equivalent to componentDidUpdate
// (but also fires on initial mount, unlike componentDidUpdate alone)
useEffect(() => {
setUser(null) // clear previous user while loading
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data))
}, [userId]) // ← re-run when userId changes
if (!user) return <p>Loading user {userId}…</p>
return <h1>{user.name}</h1>
}
// Runs after every single render (rarely what you want for side effects):
useEffect(() => {
document.title = `Rendered at ${Date.now()}`
}) // ← no dependency arrayPhase 3 — Unmount (Cleanup)
Return a function from useEffect to run cleanup code when the component unmounts — or before the effect re-runs (if dependencies changed). This is equivalent to componentWillUnmount:
function OnlineStatus({ userId }) {
const [isOnline, setIsOnline] = useState(false)
useEffect(() => {
// Setup: subscribe to a WebSocket
const socket = new WebSocket(`wss://api.example.com/presence/${userId}`)
socket.onmessage = event => {
setIsOnline(event.data === 'online')
}
// Cleanup: runs when the component unmounts OR when userId changes
return () => {
socket.close() // prevent memory leak and stale event handlers
}
}, [userId])
return <span>{isOnline ? '🟢 Online' : '⚫ Offline'}</span>
}The cleanup function is called in two situations:
Before the effect re-runs — if
userIdchanges, React calls the cleanup to close the old socket before opening a new one for the new userId.When the component unmounts — React calls cleanup one final time when the component is removed from the tree.
All Three Phases in One Component
import { useState, useEffect } from 'react'
function DocumentTitle({ title }) {
// --- MOUNT + UPDATE ---
useEffect(() => {
const previousTitle = document.title
document.title = title // side effect: update the browser tab title
// --- UNMOUNT (cleanup) ---
return () => {
document.title = previousTitle // restore when component leaves
}
}, [title]) // re-run (and clean up first) whenever title changes
return <h1>{title}</h1>
}
// Timeline when title changes from 'Home' to 'About':
// 1. React re-renders DocumentTitle with title='About'
// 2. React runs the cleanup from the previous effect → document.title = 'Home' (restore)
// 3. React runs the new effect → document.title = 'About'Class Lifecycle Methods vs Hooks
Before hooks (React 16.8), class components used named lifecycle methods. Here is the complete mapping to their hook equivalents:
Class Method | Hook Equivalent | Notes |
|---|---|---|
|
| Runs once after first render |
|
| Runs after render when dep changes |
| Return function from | Runs on unmount (and before re-run) |
| Compute during render | Derive state inline, no hook needed |
|
| Bail out of re-render if props unchanged |
|
| Rare; capture DOM value before paint |
| Error Boundary class component | No hook equivalent yet in stable React |
getDerivedStateFromProps — Compute, Don't Store
In class components, getDerivedStateFromProps was used to update state in response to prop changes. With hooks, the right approach is simpler: compute the derived value during render instead of syncing it into state:
// ✗ Old class pattern: sync props into state
class FullName extends React.Component {
static getDerivedStateFromProps(props) {
return { fullName: `${props.firstName} ${props.lastName}` }
}
render() {
return <p>{this.state.fullName}</p>
}
}
// ✓ Hooks pattern: compute during render — no state, no effect needed
function FullName({ firstName, lastName }) {
const fullName = `${firstName} ${lastName}` // derived inline
return <p>{fullName}</p>
}shouldComponentUpdate → React.memo
// ✗ Class: manual shallow comparison
class ExpensiveList extends React.PureComponent {
render() {
return this.props.items.map(item => <Item key={item.id} {...item} />)
}
}
// ✓ Hooks: React.memo for the same bail-out behaviour
const ExpensiveList = React.memo(function ExpensiveList({ items }) {
return items.map(item => <Item key={item.id} {...item} />)
})
// Custom comparison (like shouldComponentUpdate returning false):
const ExpensiveList = React.memo(
function ExpensiveList({ items, onSelect }) {
return items.map(item => (
<Item key={item.id} item={item} onSelect={onSelect} />
))
},
(prevProps, nextProps) => prevProps.items === nextProps.items
// return true = skip re-render (same output), false = re-render
)useLayoutEffect — The Synchronous Variant
useLayoutEffect has the same signature as useEffect but fires synchronously after DOM mutations and before the browser paints. Use it when you need to measure the DOM or make synchronous DOM changes to prevent visual flicker. This is the hook-based equivalent of getSnapshotBeforeUpdate + componentDidUpdate:
import { useLayoutEffect, useRef, useState } from 'react'
function Tooltip({ text, targetRef }) {
const tooltipRef = useRef(null)
const [position, setPosition] = useState({ top: 0, left: 0 })
// useLayoutEffect fires before paint — prevents flicker
useLayoutEffect(() => {
const target = targetRef.current.getBoundingClientRect()
const tooltip = tooltipRef.current.getBoundingClientRect()
setPosition({
top: target.bottom + window.scrollY,
left: target.left + (target.width - tooltip.width) / 2,
})
}, [targetRef])
return (
<div
ref={tooltipRef}
style={{ position: 'absolute', top: position.top, left: position.left }}
>
{text}
</div>
)
}Concurrent Mode and Effects
React 18's concurrent rendering changes when effects run. In concurrent mode, React can start rendering a component, pause, and resume later. However, effects always run after the entire commit phase — after React has flushed all DOM mutations. This means:
Effects are still guaranteed to fire after the component is visible in the DOM.
React 18 Strict Mode deliberately runs effects twice in development (mount → cleanup → mount) to surface components that are not cleanup-safe. This does not happen in production.
The cleanup function must fully reverse the setup — any subscription opened in setup must be closed in cleanup.
// React 18 Strict Mode dev behaviour — runs the effect twice:
useEffect(() => {
console.log('setup') // printed twice in dev
const id = setInterval(() => tick(), 1000)
return () => {
console.log('cleanup') // printed once between the two setups
clearInterval(id)
}
}, [])
// Production: setup runs once, cleanup runs on unmount.
// Dev Strict Mode: setup → cleanup → setup (to verify cleanup is correct).