React Component Lifecycle Explained
Every React component goes through a series of phases from its creation to its removal from the DOM. Understanding this component lifecycle is crucial for building efficient, bug-free React applications. Whether you're using class components with lifecycle methods or functional components with hooks, mastering the lifecycle will help you manage side effects, optimize performance, and avoid common pitfalls.
In this comprehensive guide, we'll explore the complete React component lifecycle, covering both the traditional class-based approach and the modern hooks approach. By the end, you'll understand exactly when and why each lifecycle phase occurs, and how to leverage this knowledge in your applications.
Understanding the component lifecycle helps you:
- Manage side effects - Fetch data, set up subscriptions, manipulate the DOM at the right time
- Optimize performance - Prevent unnecessary re-renders and clean up resources
- Debug effectively - Understand why components behave unexpectedly
- Write cleaner code - Structure your logic according to React's rendering model
The Three Phases of Component Lifecycle
Every React component goes through three main phases during its existence:
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
Let's visualize how these phases work together in a component's lifetime:
Complete lifecycle flow showing mounting, updating, and unmounting phases
Phase 1: Mounting
The mounting phase occurs when a component is being created and inserted into the DOM. This happens only once in the component's lifetime. During mounting, React calls several methods in a specific 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. Used when state depends on props changes over time.
render() ●
The only required method. Returns JSX, arrays, fragments, portals, strings, numbers, or null. Must be pure—no side effects, no direct DOM manipulation.
componentDidMount() ●
Best place for side effects! Called immediately after the component is mounted. Perfect for API calls, subscriptions, DOM manipulation, 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;
Never call setState() in the constructor or make API calls there. The component isn't mounted yet, so the DOM doesn't exist. Use componentDidMount() for any setup that requires the DOM or triggers side effects.
Phase 2: Updating
A component updates whenever its props or state changes. React re-renders the component and its children with the new data. This phase can occur many times throughout a component's lifetime.
Here are the methods called during an update, in order:
static getDerivedStateFromProps(props, state) ●
Called first on every update. Same as during mounting—returns object to update state or null.
shouldComponentUpdate(nextProps, nextState) ●
Performance optimization! Return false to skip re-rendering. Default returns true. Compare current and next props/state to decide.
render() ●
Re-renders with new props/state. Must be pure—same inputs should produce 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 re-render. Compare previous and current props/state. Safe for side effects, but always use conditionals to avoid infinite loops!
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>
);
}
}
Calling setState() in componentDidUpdate without a condition creates an infinite loop! Always wrap state updates in a conditional:
// ❌ 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
The unmounting phase occurs when a component is being removed from the DOM. This is your last chance to clean up any resources the component created during its lifetime.
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>
);
}
}
Failing to clean up in componentWillUnmount() causes memory leaks. If you see the warning "Can't perform a React state update on an unmounted component", you forgot to cancel an async operation. Always clean up!
Modern Approach: Hooks Lifecycle
With React Hooks, functional components can now handle all lifecycle scenarios. The useEffect hook replaces all class lifecycle methods. Here's how they map:
| Class Lifecycle Method | Hooks Equivalent |
|---|---|
constructor |
useState initialization |
componentDidMount |
useEffect(() => {}, []) |
componentDidUpdate |
useEffect(() => {}, [deps]) |
componentWillUnmount |
useEffect cleanup function |
shouldComponentUpdate |
React.memo + useMemo |
Let's see how to implement all lifecycle phases 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
Best Practices & Common Patterns
1. Data Fetching Pattern
The most common lifecycle use case is fetching data. Here's the recommended pattern with proper cleanup:
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;
};
}, []);
Summary: Lifecycle Cheatsheet
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 |
- Mounting happens once when the component is created and added to the DOM
- Updating happens whenever props or state change, triggering a re-render
- Unmounting happens when the component is removed—always clean up!
- useEffect with an empty array
[]runs on mount only - useEffect cleanup runs before the next effect and on unmount
- Always check if the component is still mounted before updating state in async code
Understanding the React component lifecycle is fundamental to building robust applications. Whether you're using class components or modern hooks, the mental model remains the same: components are born (mount), live and change (update), and eventually die (unmount). Master this flow, and you'll write cleaner, more efficient React code.
