React Lifecycle Methods Explained — Mounting, Updating and Cleanup
Every React app you've ever used — a Twitter feed refreshing in real time, a form that validates as you type, a dashboard that polls an API every 30 seconds — depends on lifecycle methods working correctly behind the scenes. They're not a niche feature; they're the engine that drives dynamic behavior. If you've ever seen a component that shows stale data, triggers a memory leak, or fires an API call 400 times, a misunderstood lifecycle method was almost certainly the culprit.
The problem lifecycle methods solve is timing. JavaScript is asynchronous and the DOM is constantly changing, so you need a reliable way to say 'run this code ONLY after the component appears on screen' or 'clean this up BEFORE the component disappears.' Without lifecycle hooks, you'd be firing code at the wrong moment — fetching data before the component exists, or leaving open WebSocket connections after the user has navigated away.
After reading this article you'll know exactly which lifecycle method to reach for in three critical scenarios: fetching data on load, responding to prop or state changes, and cleaning up subscriptions or timers. You'll also understand the modern hooks equivalents so you can work confidently in both class-based and functional codebases.
The Three Phases Every React Component Goes Through
React components have a predictable life. They're born (mounting), they change (updating), and they die (unmounting). Each phase gives you specific hooks to run your own code at exactly the right moment.
Mounting happens once — when the component first appears in the DOM. This is where you kick off initial data fetching, set up subscriptions, or read from localStorage. It only runs once per component instance, which is why so many developers reach for it first.
Updating happens every time props or state change. React re-renders the component and gives you lifecycle hooks both before and after that re-render. This is where you respond to changes — maybe a user selected a different user ID from a dropdown and you need to fetch that user's data.
Unmounting happens when the component is removed from the DOM — the user navigates away, a conditional renders the component out, or the parent unmounts. This is your cleanup phase. Anything you set up in mounting that isn't React-managed (timers, event listeners, WebSocket connections) must be torn down here, or you'll leak memory.
Understanding that these three phases exist — and are sequential — is the foundation everything else builds on.
import React, { Component } from 'react'; // A simple component that logs each lifecycle phase clearly // so you can see the ORDER in which they fire in the console class UserProfileCard extends Component { constructor(props) { super(props); // constructor runs first — before anything is painted to the DOM this.state = { profileData: null, isLoading: true, }; console.log('[1] constructor — component instance created'); } // Fires ONCE after the component appears in the DOM componentDidMount() { console.log('[3] componentDidMount — component is now visible in the DOM'); // Safe to fetch data here because the DOM node exists this.fetchUserProfile(this.props.userId); } // Fires after EVERY re-render caused by props or state change componentDidUpdate(previousProps, previousState) { console.log('[5] componentDidUpdate — something changed and React re-rendered'); // IMPORTANT: always compare previous vs current before acting // Without this check you get an infinite loop (fetch → setState → re-render → fetch...) if (previousProps.userId !== this.props.userId) { console.log('userId prop changed — fetching new profile'); this.fetchUserProfile(this.props.userId); } } // Fires just before the component is removed from the DOM componentWillUnmount() { console.log('[6] componentWillUnmount — time to clean up'); // If we had a timer or subscription set up in componentDidMount, // we'd cancel it here to avoid memory leaks clearTimeout(this.refreshTimer); } fetchUserProfile(userId) { this.setState({ isLoading: true }); // Simulating an API call with setTimeout this.refreshTimer = setTimeout(() => { this.setState({ profileData: { id: userId, name: 'Ada Lovelace', role: 'Engineer' }, isLoading: false, }); console.log('[4] setState called — triggers re-render and componentDidUpdate'); }, 500); } render() { // render() fires BEFORE componentDidMount on first load // and BEFORE componentDidUpdate on subsequent renders console.log('[2/re-render] render — React is building the virtual DOM'); const { isLoading, profileData } = this.state; if (isLoading) return <p>Loading profile...</p>; return ( <div className="profile-card"> <h2>{profileData.name}</h2> <p>Role: {profileData.role}</p> </div> ); } } export default UserProfileCard;
[2/re-render] render — React is building the virtual DOM
[3] componentDidMount — component is now visible in the DOM
// ...500ms later after setTimeout resolves...
[4] setState called — triggers re-render and componentDidUpdate
[2/re-render] render — React is building the virtual DOM
[5] componentDidUpdate — something changed and React re-rendered
// ...if userId prop changes...
[5] componentDidUpdate — something changed and React re-rendered
userId prop changed — fetching new profile
// ...on unmount...
[6] componentWillUnmount — time to clean up
componentDidMount — Your Go-To for Data Fetching and Subscriptions
componentDidMount is the most commonly used lifecycle method, and for good reason — it fires exactly once, right after the component is painted to the screen. The DOM node exists, refs are populated, and you're guaranteed a real browser environment. That makes it the perfect place to kick off async operations.
The key insight most tutorials miss: you're not just 'fetching data here because that's the pattern.' You're fetching here because React guarantees the component is mounted before this runs, so any setState calls you make in response to the fetch will trigger a re-render correctly. If you tried to call setState before mounting, React would either throw or silently discard your update.
Beyond data fetching, componentDidMount is where you set up anything that needs a real DOM node — third-party charting libraries, manual event listeners on window or document, or WebSocket connections. The golden rule: if you set it up here, you must tear it down in componentWillUnmount.
With hooks, componentDidMount maps to useEffect(() => { ... }, []) — the empty dependency array is what makes it run once.
import React, { Component } from 'react'; // Real-world pattern: a dashboard that fetches data on mount // AND sets up a polling interval to refresh every 60 seconds class WeatherDashboard extends Component { constructor(props) { super(props); this.state = { weatherData: null, errorMessage: null, lastUpdated: null, }; // We'll store the interval ID so we can clear it on unmount this.pollingInterval = null; } async componentDidMount() { // First fetch happens immediately when the component mounts await this.loadWeatherData(); // Then we set up polling — fetch fresh data every 60 seconds // Store the interval ID so componentWillUnmount can clean it up this.pollingInterval = setInterval(async () => { console.log('Polling: refreshing weather data...'); await this.loadWeatherData(); }, 60000); // 60,000ms = 60 seconds } async loadWeatherData() { const { cityName } = this.props; try { // In a real app this would be: fetch(`/api/weather?city=${cityName}`) const response = await fetch( `https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.1¤t_weather=true` ); if (!response.ok) { throw new Error(`API returned status ${response.status}`); } const data = await response.json(); // setState here is safe — component is definitely mounted this.setState({ weatherData: data.current_weather, errorMessage: null, lastUpdated: new Date().toLocaleTimeString(), }); } catch (fetchError) { // Always handle errors — never let a rejected promise go silent this.setState({ errorMessage: `Could not load weather: ${fetchError.message}`, }); } } componentWillUnmount() { // CRITICAL: clear the interval when the component unmounts // Without this, the interval keeps firing even after the component // is gone, causing setState calls on an unmounted component if (this.pollingInterval) { clearInterval(this.pollingInterval); console.log('WeatherDashboard unmounted — polling interval cleared'); } } render() { const { weatherData, errorMessage, lastUpdated } = this.state; if (errorMessage) { return <div className="error-banner">{errorMessage}</div>; } if (!weatherData) { return <div className="skeleton-loader">Loading weather...</div>; } return ( <div className="weather-card"> <h3>London Weather</h3> <p>Temperature: {weatherData.temperature}°C</p> <p>Wind Speed: {weatherData.windspeed} km/h</p> <small>Last updated: {lastUpdated}</small> </div> ); } } export default WeatherDashboard;
Polling: refreshing weather data... (fires after 60s)
Polling: refreshing weather data... (fires after 120s)
// When user navigates away (component unmounts):
WeatherDashboard unmounted — polling interval cleared
// Rendered output:
// Temperature: 14°C
// Wind Speed: 22 km/h
// Last updated: 10:34:05 AM
componentDidUpdate — Responding Intelligently to Change
If componentDidMount is 'do this once on load,' then componentDidUpdate is 'do this whenever something specific changes.' It receives the previous props and previous state as arguments, which is what makes it powerful — you can compare old vs new and react only when it matters.
The single most important rule: always wrap your logic in a conditional comparison. componentDidUpdate fires after every re-render, including renders caused by the setState call inside it. Without a comparison guard, you'll create an infinite loop: state changes → componentDidUpdate fires → setState called → state changes → componentDidUpdate fires...
The comparison pattern (previousProps.someValue !== this.props.someValue) is essentially saying 'only do the expensive work if the relevant input actually changed.' This is the same thinking behind React.memo and useMemo — skip work that doesn't need to happen.
A real-world use case: imagine a data table where the user can select a date range. Every time the date range changes, you need to re-fetch the report. componentDidUpdate lets you watch for exactly that change and respond to it, without rebuilding the entire component.
In hooks, this maps to useEffect with specific dependencies: useEffect(() => { ... }, [dateRange]).
import React, { Component } from 'react'; // Real-world pattern: a report that re-fetches when filters change // This is the most common use case for componentDidUpdate class SalesReportTable extends Component { constructor(props) { super(props); this.state = { reportRows: [], isFetching: false, totalRevenue: 0, }; } componentDidMount() { // Fetch the initial report using the starting prop values this.fetchSalesReport(this.props.startDate, this.props.endDate, this.props.regionFilter); } componentDidUpdate(previousProps) { // Check if ANY of the filter props changed // We check ALL relevant props in one condition to avoid multiple fetches const dateRangeChanged = previousProps.startDate !== this.props.startDate || previousProps.endDate !== this.props.endDate; const regionChanged = previousProps.regionFilter !== this.props.regionFilter; // Only re-fetch if something that affects the report actually changed // This guard is what prevents the infinite loop if (dateRangeChanged || regionChanged) { console.log('Filters changed — fetching updated sales report'); this.fetchSalesReport( this.props.startDate, this.props.endDate, this.props.regionFilter ); } // You can also react to STATE changes, not just prop changes // For example: scroll to bottom when new rows are added if (previousProps.reportRows !== this.state.reportRows && this.tableRef) { this.tableRef.scrollTop = this.tableRef.scrollHeight; } } async fetchSalesReport(startDate, endDate, region) { // Set loading state — triggers a re-render but componentDidUpdate // won't re-fetch because the props haven't changed this.setState({ isFetching: true }); try { // Simulated API response const mockRows = [ { id: 1, product: 'Enterprise License', revenue: 12000, region }, { id: 2, product: 'Pro Subscription', revenue: 3400, region }, { id: 3, product: 'Consulting Hours', revenue: 5600, region }, ]; const totalRevenue = mockRows.reduce((sum, row) => sum + row.revenue, 0); this.setState({ reportRows: mockRows, totalRevenue, isFetching: false, }); } catch (error) { this.setState({ isFetching: false }); console.error('Failed to fetch report:', error); } } render() { const { reportRows, isFetching, totalRevenue } = this.state; const { startDate, endDate, regionFilter } = this.props; return ( <div className="report-container"> <h3> Sales Report: {startDate} to {endDate} ({regionFilter}) </h3> {isFetching ? ( <p>Refreshing data...</p> ) : ( <table> <thead> <tr><th>Product</th><th>Revenue</th><th>Region</th></tr> </thead> <tbody> {reportRows.map((row) => ( <tr key={row.id}> <td>{row.product}</td> <td>${row.revenue.toLocaleString()}</td> <td>{row.region}</td> </tr> ))} </tbody> <tfoot> <tr><td colSpan="2"><strong>Total: ${totalRevenue.toLocaleString()}</strong></td></tr> </tfoot> </table> )} </div> ); } } export default SalesReportTable;
Fetching sales report for: 2024-01-01 to 2024-01-31 (North America)
// User changes region to 'Europe':
Filters changed — fetching updated sales report
// Rendered table:
// Product | Revenue | Region
// Enterprise License | $12,000 | Europe
// Pro Subscription | $3,400 | Europe
// Consulting Hours | $5,600 | Europe
// Total: $21,000
Hooks vs Class Lifecycle Methods — The Modern Equivalent
Class lifecycle methods aren't going anywhere — you'll encounter them in every legacy codebase. But since React 16.8, hooks have become the modern way to handle the same concerns in functional components. Knowing both, and how they map to each other, is what separates a solid React developer from one who only knows the happy path.
The key mental shift: useEffect doesn't map one-to-one to a single lifecycle method. It can replicate all three — mounting, updating, and unmounting — depending on how you configure it. The dependency array is the control mechanism.
An empty array [] means 'run once after mount' — that's componentDidMount. A populated array [userId] means 'run after mount AND after any render where userId changed' — that's componentDidMount plus componentDidUpdate with a comparison guard built in. No array at all means 'run after every render' — dangerous, but it exists. And the return value of useEffect is the cleanup function — that's componentWillUnmount.
The functional approach collapses three methods into one concept, which is more composable. But the class approach makes the phases more explicit, which can actually be easier to reason about when learning.
import React, { Component, useState, useEffect } from 'react'; // ───────────────────────────────────────────────────────── // CLASS COMPONENT VERSION — explicit lifecycle methods // ───────────────────────────────────────────────────────── class NotificationPanelClass extends Component { constructor(props) { super(props); this.state = { notifications: [], connectionStatus: 'disconnected' }; this.socketConnection = null; } componentDidMount() { // Set up a WebSocket-like subscription when the component mounts this.connectToNotificationStream(this.props.userId); } componentDidUpdate(previousProps) { // If the user changes (e.g. admin views another user's notifications), // disconnect the old stream and connect to the new one if (previousProps.userId !== this.props.userId) { this.disconnectFromStream(); this.connectToNotificationStream(this.props.userId); } } componentWillUnmount() { // Always close the connection when the panel is hidden/removed this.disconnectFromStream(); } connectToNotificationStream(userId) { console.log(`Connecting to stream for user: ${userId}`); this.setState({ connectionStatus: 'connected' }); // Simulating a real-time notification stream this.socketConnection = setInterval(() => { this.setState((previousState) => ({ notifications: [ ...previousState.notifications, { id: Date.now(), message: `New alert for user ${userId}` }, ], })); }, 3000); } disconnectFromStream() { if (this.socketConnection) { clearInterval(this.socketConnection); this.socketConnection = null; this.setState({ connectionStatus: 'disconnected' }); console.log('Disconnected from notification stream'); } } render() { const { notifications, connectionStatus } = this.state; return ( <div> <span>Status: {connectionStatus}</span> <ul>{notifications.map((n) => <li key={n.id}>{n.message}</li>)}</ul> </div> ); } } // ───────────────────────────────────────────────────────── // FUNCTIONAL COMPONENT VERSION — same logic, hooks style // Notice how useEffect with a cleanup return collapses // componentDidMount + componentDidUpdate + componentWillUnmount // ───────────────────────────────────────────────────────── function NotificationPanelHooks({ userId }) { const [notifications, setNotifications] = useState([]); const [connectionStatus, setConnectionStatus] = useState('disconnected'); useEffect(() => { // This block = componentDidMount + componentDidUpdate (when userId changes) console.log(`Connecting to stream for user: ${userId}`); setConnectionStatus('connected'); const streamInterval = setInterval(() => { setNotifications((previousNotifications) => [ ...previousNotifications, { id: Date.now(), message: `New alert for user ${userId}` }, ]); }, 3000); // The return function = componentWillUnmount // React also calls this cleanup BEFORE re-running the effect // when userId changes — so we get automatic reconnection logic return () => { clearInterval(streamInterval); setConnectionStatus('disconnected'); console.log('Disconnected from notification stream'); }; }, [userId]); // Only re-run this effect when userId changes return ( <div> <span>Status: {connectionStatus}</span> <ul>{notifications.map((n) => <li key={n.id}>{n.message}</li>)}</ul> </div> ); } export { NotificationPanelClass, NotificationPanelHooks };
// On mount (userId = 'user_42'):
Connecting to stream for user: user_42
// After 3s: renders 'New alert for user user_42'
// After 6s: renders second alert
// If userId prop changes to 'user_99':
Disconnected from notification stream
Connecting to stream for user: user_99
// On unmount:
Disconnected from notification stream
| Aspect | Class Lifecycle Methods | useEffect Hook (Functional) |
|---|---|---|
| Syntax | Separate named methods (componentDidMount, etc.) | Single useEffect() with config options |
| Mount once | componentDidMount() | useEffect(() => {}, []) |
| Update on change | componentDidUpdate(prevProps, prevState) | useEffect(() => {}, [dependency]) |
| Cleanup / unmount | componentWillUnmount() | return () => {} inside useEffect |
| Logic co-location | Setup and teardown split across methods | Setup and teardown in same block |
| Multiple concerns | All concerns mixed in one method | Separate useEffect per concern |
| Learning curve | Explicit and readable for beginners | More composable, but requires mental model shift |
| Legacy codebase likelihood | Very common in pre-2019 code | Standard in modern React codebases |
| shouldComponentUpdate equivalent | shouldComponentUpdate() method | React.memo() + useMemo() |
🎯 Key Takeaways
- componentDidMount fires once after the DOM is ready — it's your safe zone for data fetching, subscriptions, and third-party library setup, not the constructor.
- componentDidUpdate gives you previousProps and previousState — always compare old vs new before acting, or you'll create an infinite render loop that's painful to debug.
- componentWillUnmount is non-negotiable cleanup — every setInterval, addEventListener, or WebSocket you create must be destroyed here, or you'll leak memory into every other component in the app.
- useEffect collapses all three lifecycle phases into one API: the body is mount/update logic, the dependency array controls when it re-runs, and the return function is your cleanup — understanding this mapping makes you fluent in both class and functional React.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Missing the comparison guard in componentDidUpdate — Symptom: infinite loop causes browser tab to freeze and console floods with thousands of log lines — Fix: always wrap your logic with if (previousProps.someValue !== this.props.someValue) before calling setState or firing any side effect inside componentDidUpdate.
- ✕Mistake 2: Not cleaning up timers or subscriptions in componentWillUnmount — Symptom: React logs 'Warning: Can't perform a React state update on an unmounted component' in the console, and you see ghost data appearing in other components — Fix: store any interval, timeout, or subscription ID in a class property (this.timerID = setInterval(...)) and call clearInterval(this.timerID) inside componentWillUnmount.
- ✕Mistake 3: Fetching data inside render() instead of componentDidMount — Symptom: the API gets called on every single re-render (potentially hundreds of times), you see duplicate network requests in DevTools, and the component flickers constantly — Fix: move all side effects to componentDidMount or componentDidUpdate. render() must be a pure function with zero side effects — it only describes what the UI looks like given current state and props.
Interview Questions on This Topic
- QCan you walk me through the React component lifecycle phases in order, and tell me which method you'd use to fetch data from an API and why?
- QWhat happens if you call setState inside componentDidUpdate without a conditional check? How would you debug that in production?
- QHow does useEffect with a dependency array replicate componentDidUpdate — and how is it different from calling componentDidUpdate in a class component with the same prop comparison logic?
Frequently Asked Questions
What is the order of React lifecycle methods when a component first loads?
The order is: constructor → render → componentDidMount. The constructor sets up initial state, render describes the UI structure, and componentDidMount fires after the component is actually inserted into the real DOM. This is why data fetching belongs in componentDidMount and not in the constructor — the DOM doesn't exist yet when the constructor runs.
Is componentDidMount called on every re-render?
No — componentDidMount fires exactly once per component instance, right after the initial mount. Subsequent re-renders (caused by state or prop changes) trigger componentDidUpdate instead. If you want code that runs on every render, you'd use componentDidUpdate, but almost always with a conditional guard to avoid infinite loops.
Why does React give a warning about setState on an unmounted component, and how do I fix it?
This warning appears when an async operation (like a fetch call) completes after the component has already been removed from the DOM, and then calls setState. The fix is to either cancel the async operation in componentWillUnmount using an AbortController (for fetch requests) or a clearTimeout/clearInterval for timers, or track a mounted flag — set this.isMounted = true in componentDidMount and check it before calling setState in your async callback.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.