React Component Lifecycle Explained
You add a useEffect that fetches some data, open the Network tab, and see the request fire twice. Or a search box shows results for the previous query because an old request finished last. Or the console fills up with errors from a WebSocket that kept running after you navigated away. Almost every one of these bugs comes down to code running at the wrong point in the component lifecycle.
This post walks through the three phases (mounting, updating, unmounting), first with class lifecycle methods because they make each step explicit, then with hooks, which is how you'll write most new code. After that come the patterns I reach for most and the mistakes that cause the bugs above.
In practice it helps with four things:
- Side effects: fetching data, setting up subscriptions and touching the DOM at a point where it's safe
- Performance: skipping renders you don't need and releasing resources you're done with
- Debugging: figuring out why an effect ran, didn't run, or ran twice
- Structure: putting logic where React expects it instead of fighting the render model
The Three Phases of Component Lifecycle
A component's life has three parts:
Mounting
Component is created and inserted into the DOM for the first time
Updating
Component re-renders due to changes in props or state
Unmounting
Component is removed from the DOM and cleaned up
Here's how they connect for a class component:
Complete lifecycle flow showing mounting, updating, and unmounting phases
Phase 1: Mounting
Mounting happens once per component instance: React creates it, renders it, and puts the result in the DOM. If the component is removed and shown again later, that's a brand new mount with fresh state. For class components, React calls these methods in order:
constructor(props) ●
Called first when the component is created. Use it to initialize state and bind methods. Always call super(props) first. Avoid side effects here.
static getDerivedStateFromProps(props, state) ●
Rarely needed. Called right before rendering. Returns an object to update state, or null. Only for the unusual case where state has to follow props over time.
render() ●
The only required method. Returns JSX, arrays, fragments, portals, strings, numbers, or null. It has to be pure: no side effects and no direct DOM manipulation.
componentDidMount() ●
This is where side effects go. It runs right after the component is in the DOM, so it's the place for API calls, subscriptions, DOM measurements and timers.
import React, { Component } from 'react';
class UserProfile extends Component {
// 1. Constructor - Initialize state
constructor(props) {
super(props);
this.state = {
user: null,
loading: true,
error: null
};
console.log('1. Constructor called');
}
// 2. getDerivedStateFromProps - Rarely needed
static getDerivedStateFromProps(props, state) {
console.log('2. getDerivedStateFromProps called');
// Return null if no state update needed
return null;
}
// 3. Render - Return JSX
render() {
console.log('3. Render called');
const { user, loading, error } = this.state;
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div className="user-profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// 4. componentDidMount - Side effects here!
componentDidMount() {
console.log('4. componentDidMount called');
// Perfect place for API calls
fetch(`/api/users/${this.props.userId}`)
.then(res => res.json())
.then(user => this.setState({ user, loading: false }))
.catch(error => this.setState({ error: error.message, loading: false }));
}
}
export default UserProfile;
Don't call setState() in the constructor (assign this.state directly) and don't make API calls there. The component isn't mounted yet, so there's no DOM to work with. Anything that needs the DOM or causes a side effect belongs in componentDidMount().
Phase 2: Updating
Any time props or state change, React re-renders the component and its children with the new data. Unlike mounting, this can happen hundreds of times while the component is on screen, so whatever you put here runs a lot.
The methods called during an update, in order:
static getDerivedStateFromProps(props, state) ●
Called first on every update. Works the same as during mounting: return an object to update state, or null.
shouldComponentUpdate(nextProps, nextState) ●
A performance escape hatch. Return false to skip the re-render (the default is true). Compare current and next props/state to decide.
render() ●
Re-renders with the new props/state. Still has to be pure: the same inputs should give the same output.
getSnapshotBeforeUpdate(prevProps, prevState) ●
Called right before DOM updates. Capture scroll position, element dimensions, etc. Return value is passed to componentDidUpdate.
componentDidUpdate(prevProps, prevState, snapshot) ●
Called after the re-render. Compare previous and current props/state here. Side effects are fine, but wrap them in a condition or you'll get an infinite loop.
import React, { Component } from 'react';
class SearchResults extends Component {
state = {
results: [],
loading: false
};
// Optimization: Skip re-render if props didn't change
shouldComponentUpdate(nextProps, nextState) {
// Only update if query changed or results changed
if (nextProps.query !== this.props.query) {
return true;
}
if (nextState.results !== this.state.results) {
return true;
}
return false; // Skip re-render
}
// Capture scroll position before update
getSnapshotBeforeUpdate(prevProps, prevState) {
// If we're adding new results, capture scroll position
if (prevState.results.length < this.state.results.length) {
const list = this.listRef.current;
return list.scrollHeight - list.scrollTop;
}
return null;
}
// Handle updates after render
componentDidUpdate(prevProps, prevState, snapshot) {
// If query changed, fetch new results
if (prevProps.query !== this.props.query) {
this.fetchResults(this.props.query);
}
// Restore scroll position after adding results
if (snapshot !== null) {
const list = this.listRef.current;
list.scrollTop = list.scrollHeight - snapshot;
}
}
fetchResults = async (query) => {
this.setState({ loading: true });
const response = await fetch(`/api/search?q=${query}`);
const results = await response.json();
this.setState({ results, loading: false });
};
listRef = React.createRef();
render() {
const { results, loading } = this.state;
return (
<div className="search-results" ref={this.listRef}>
{loading && <div className="loading">Searching...</div>}
{results.map(result => (
<div key={result.id} className="result">
{result.title}
</div>
))}
</div>
);
}
}
One thing to notice in that example: shouldComponentUpdate only checks query and results. When fetchResults sets loading: true, neither has changed, so the render is skipped and "Searching..." never shows up. That's the usual trap with hand-written shouldComponentUpdate: forget one field and part of your UI silently stops updating. PureComponent (a shallow compare of all props and state) is the safer default.
Calling setState() in componentDidUpdate without a condition triggers another update, which calls componentDidUpdate again, and so on. React eventually stops it with "Maximum update depth exceeded". Wrap the update in a check:
// ❌ BAD - Infinite loop!
componentDidUpdate() {
this.setState({ updated: true });
}
// ✅ GOOD - Conditional update
componentDidUpdate(prevProps) {
if (prevProps.id !== this.props.id) {
this.setState({ updated: true });
}
}
Phase 3: Unmounting
When a component is about to leave the DOM, you get one last call to release whatever it set up. Anything you skip here keeps running in the background with no component attached to it.
componentWillUnmount()
Called immediately before a component is unmounted and destroyed. Use it to:
- Cancel network requests
- Remove event listeners
- Clear timers (setTimeout, setInterval)
- Unsubscribe from subscriptions
- Clean up any DOM elements created outside React
import React, { Component } from 'react';
class RealTimeStock extends Component {
state = {
price: null,
connected: false
};
componentDidMount() {
// Set up WebSocket connection
this.socket = new WebSocket('wss://stocks.api/live');
this.socket.onopen = () => {
this.setState({ connected: true });
this.socket.send(JSON.stringify({ subscribe: this.props.symbol }));
};
this.socket.onmessage = (event) => {
const data = JSON.parse(event.data);
this.setState({ price: data.price });
};
// Set up interval for UI updates
this.intervalId = setInterval(() => {
this.setState({ lastUpdate: new Date() });
}, 1000);
// Add window resize listener
window.addEventListener('resize', this.handleResize);
}
// CRITICAL: Clean up everything!
componentWillUnmount() {
// 1. Close WebSocket connection
if (this.socket) {
this.socket.close();
}
// 2. Clear interval
if (this.intervalId) {
clearInterval(this.intervalId);
}
// 3. Remove event listeners
window.removeEventListener('resize', this.handleResize);
// 4. Cancel any pending requests
if (this.abortController) {
this.abortController.abort();
}
console.log('Cleanup complete!');
}
handleResize = () => {
// Handle window resize
};
render() {
const { price, connected } = this.state;
return (
<div className="stock-ticker">
<span className={connected ? 'connected' : 'disconnected'}>
{connected ? '●' : '○'}
</span>
<span>{this.props.symbol}: ${price || '---'}</span>
</div>
);
}
}
Skipping cleanup in componentWillUnmount() leaks memory: open sockets, intervals and listeners that hold references to a dead component. On React 17 and older you'd see "Can't perform a React state update on an unmounted component". React 18 removed that warning, so on newer versions the leak is silent and you have to find it yourself (the Memory tab in DevTools, or a log line in your cleanup).
Modern Approach: Hooks Lifecycle
Function components don't have lifecycle methods. You describe effects with useEffect and React decides when to run them based on the dependency array. The mapping isn't exact (an effect is closer to "keep this in sync with these values" than to a lifecycle event), but it's a good starting point:
| Class Lifecycle Method | Hooks Equivalent |
|---|---|
constructor |
useState initialization |
componentDidMount |
useEffect(() => {}, []) |
componentDidUpdate |
useEffect(() => {}, [deps]) |
componentWillUnmount |
useEffect cleanup function |
shouldComponentUpdate |
React.memo + useMemo |
Here's each phase written with hooks:
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
// Initialize state (replaces constructor)
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// componentDidMount equivalent
// Empty dependency array = runs once on mount
useEffect(() => {
console.log('Component mounted!');
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, []); // ← Empty array = mount only
if (loading) return <div>Loading...</div>;
return <h1>{user.name}</h1>;
}
import { useState, useEffect, useRef } from 'react';
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const prevQuery = useRef(query);
// componentDidUpdate equivalent
// Runs when 'query' changes
useEffect(() => {
// Skip if query hasn't changed
if (prevQuery.current === query) return;
console.log('Query changed from', prevQuery.current, 'to', query);
prevQuery.current = query;
// Fetch new results
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, [query]); // ← Runs when query changes
// Runs on EVERY render (rarely needed)
useEffect(() => {
console.log('Component rendered');
}); // ← No dependency array = every render
return (
<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
);
}
import { useState, useEffect } from 'react';
function RealTimeData({ channel }) {
const [data, setData] = useState(null);
useEffect(() => {
// Setup (componentDidMount)
const socket = new WebSocket(`wss://api/${channel}`);
socket.onmessage = (event) => {
setData(JSON.parse(event.data));
};
const intervalId = setInterval(() => {
console.log('Interval tick');
}, 1000);
// Cleanup function (componentWillUnmount)
return () => {
console.log('Cleaning up!');
socket.close();
clearInterval(intervalId);
};
}, [channel]); // Cleanup runs when channel changes too!
return <div>Data: {JSON.stringify(data)}</div>;
}
// The cleanup function runs:
// 1. When component unmounts
// 2. Before effect re-runs (when deps change)
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
function UserDashboard({ userId }) {
// State initialization (constructor replacement)
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
// Refs persist across renders
const mountedRef = useRef(true);
const prevUserId = useRef(userId);
// Mount effect - runs once
useEffect(() => {
console.log('Component mounted');
mountedRef.current = true;
return () => {
console.log('Component unmounting');
mountedRef.current = false;
};
}, []);
// Effect that responds to userId changes
useEffect(() => {
const controller = new AbortController();
async function fetchUserData() {
try {
setLoading(true);
const [userRes, postsRes] = await Promise.all([
fetch(`/api/users/${userId}`, { signal: controller.signal }),
fetch(`/api/users/${userId}/posts`, { signal: controller.signal })
]);
if (!mountedRef.current) return; // Prevent state update if unmounted
const userData = await userRes.json();
const postsData = await postsRes.json();
setUser(userData);
setPosts(postsData);
setLoading(false);
console.log(`User changed: ${prevUserId.current} → ${userId}`);
prevUserId.current = userId;
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error);
}
}
}
fetchUserData();
// Cleanup: abort fetch if userId changes or unmount
return () => controller.abort();
}, [userId]);
// Memoized value (performance optimization)
const postCount = useMemo(() => posts.length, [posts]);
// Memoized callback
const handleRefresh = useCallback(() => {
// Trigger re-fetch by updating a state
}, []);
if (loading) return <div>Loading...</div>;
return (
<div>
<h1>{user?.name}</h1>
<p>{postCount} posts</p>
<button onClick={handleRefresh}>Refresh</button>
</div>
);
}
Visual: useEffect Execution Flow
useEffect cleanup runs before each new effect and on unmount
In development, React 18+ with <StrictMode> mounts every component, immediately unmounts it, and mounts it again. So a useEffect(fn, []) runs setup, cleanup, setup, and you'll see two requests in the Network tab. This only happens in dev builds. Don't "fix" it with a useRef flag; it's there to expose effects whose cleanup is missing or broken. If your component behaves correctly after the second mount, you're fine.
Best Practices & Common Patterns
1. Data Fetching Pattern
Fetching data is the effect you'll write most. This version handles errors, aborts the request when the URL changes or the component unmounts, and ignores late responses:
import { useState, useEffect } from 'react';
function useDataFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Create abort controller for cleanup
const controller = new AbortController();
let isMounted = true;
async function fetchData() {
try {
setLoading(true);
setError(null);
const response = await fetch(url, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
// Only update state if still mounted
if (isMounted) {
setData(result);
setLoading(false);
}
} catch (err) {
if (err.name !== 'AbortError' && isMounted) {
setError(err.message);
setLoading(false);
}
}
}
fetchData();
// Cleanup function
return () => {
isMounted = false;
controller.abort();
};
}, [url]); // Re-fetch when URL changes
return { data, loading, error };
}
// Usage
function UserList() {
const { data: users, loading, error } = useDataFetch('/api/users');
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
return (
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
2. Event Listener Pattern
import { useState, useEffect, useCallback } from 'react';
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
// Memoize handler to prevent unnecessary re-subscriptions
const handleResize = useCallback(() => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
}, []);
useEffect(() => {
// Add listener on mount
window.addEventListener('resize', handleResize);
// Remove listener on unmount
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
return size;
}
// Usage
function ResponsiveComponent() {
const { width, height } = useWindowSize();
return (
<div>
Window: {width} x {height}
{width < 768 ? <MobileView /> : <DesktopView />}
</div>
);
}
3. Timer Pattern
import { useState, useEffect, useRef } from 'react';
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
// Remember the latest callback
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval
useEffect(() => {
if (delay === null) return; // Paused
const tick = () => savedCallback.current();
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
// Usage: Auto-refreshing component
function LiveClock() {
const [time, setTime] = useState(new Date());
useInterval(() => {
setTime(new Date());
}, 1000);
return <div>{time.toLocaleTimeString()}</div>;
}
// Usage: Pausable timer
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useInterval(
() => setSeconds(s => s + 1),
isRunning ? 1000 : null // null = paused
);
return (
<div>
<p>{seconds} seconds</p>
<button onClick={() => setIsRunning(!isRunning)}>
{isRunning ? 'Pause' : 'Start'}
</button>
</div>
);
}
Common Mistakes to Avoid
❌ Missing Dependency Array
// ❌ Runs on EVERY render - usually wrong!
useEffect(() => {
fetchData();
});
// ✅ Runs only on mount
useEffect(() => {
fetchData();
}, []);
// ✅ Runs when userId changes
useEffect(() => {
fetchData(userId);
}, [userId]);
❌ Object/Array Dependencies
// ❌ Creates new object every render → infinite loop!
useEffect(() => {
fetch(options.url);
}, [{ url: '/api/data' }]); // New object reference each time!
// ✅ Use primitive values
const url = '/api/data';
useEffect(() => {
fetch(url);
}, [url]);
// ✅ Or useMemo for complex objects
const options = useMemo(() => ({ url: '/api/data' }), []);
useEffect(() => {
fetch(options.url);
}, [options]);
❌ Forgetting Cleanup
// ❌ Memory leak - subscription never cleaned up
useEffect(() => {
const subscription = eventEmitter.subscribe(handleEvent);
// No cleanup!
}, []);
// ✅ Always clean up subscriptions
useEffect(() => {
const subscription = eventEmitter.subscribe(handleEvent);
return () => {
subscription.unsubscribe();
};
}, []);
❌ State Updates After Unmount
// ❌ Can cause "Can't perform state update on unmounted component"
useEffect(() => {
fetchData().then(data => {
setData(data); // Component might be unmounted!
});
}, []);
// ✅ Track mounted state
useEffect(() => {
let isMounted = true;
fetchData().then(data => {
if (isMounted) {
setData(data);
}
});
return () => {
isMounted = false;
};
}, []);
Lifecycle Cheatsheet
Keep this table handy when you're porting a class component to hooks or reading older code:
Quick Reference
| When | Class Method | Hook Pattern |
|---|---|---|
| Initialize state | constructor() |
useState(initialValue) |
| After first render | componentDidMount() |
useEffect(fn, []) |
| After every render | componentDidUpdate() |
useEffect(fn) |
| When deps change | componentDidUpdate() + check |
useEffect(fn, [deps]) |
| Before unmount | componentWillUnmount() |
useEffect(() => cleanup, []) |
| Optimize renders | shouldComponentUpdate() |
React.memo() + useMemo |
When I'd still use class lifecycle methods (and when I'd skip useEffect)
For new code I write function components and hooks. Classes still have a couple of jobs hooks can't do:
- Error boundaries:
componentDidCatchandstatic getDerivedStateFromErrorhave no hook equivalent. You either write one small class or use a package likereact-error-boundary, which is a class under the hood. getSnapshotBeforeUpdate: also has no hook version.useLayoutEffectcovers most scroll-restoring cases, but it runs after the DOM change, so you have to keep the "before" value in a ref yourself.- Existing class code that works: I wouldn't rewrite it just to use hooks. Convert a component when you're already changing it for another reason.
The other half is knowing when an effect is the wrong tool. If you're computing a value from props or state (filtering a list, building a full name), do it during render, maybe with useMemo. Copying it into state through an effect causes an extra render and a frame where the two are out of sync. If something should happen because the user clicked a button, put it in the click handler, not in an effect watching a flag. And for real data fetching in a production app, the hand-rolled useDataFetch above gets you started, but it has no caching, deduping or retries. A library like TanStack Query or SWR, or your framework's loaders, handles those for you.
When something does belong in an effect, the questions are always the same: what does it set up, what values does it depend on, and what undoes it? If you can answer all three, the lifecycle stops being the source of your bugs.