Skip to content
Home JavaScript React Lifecycle Methods — Why Your setInterval Leaks Memory

React Lifecycle Methods — Why Your setInterval Leaks Memory

Where developers are forged. · Structured learning · Free forever.
📍 Part of: React.js → Topic 15 of 47
Dashboard tab consumes 200MB immediately, climbs to 1.
⚙️ Intermediate — basic JavaScript knowledge assumed
In this tutorial, you'll learn
Dashboard tab consumes 200MB immediately, climbs to 1.
  • 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • React components have three predictable phases: mounting, updating, and unmounting
  • componentDidMount runs once after DOM insertion — your safe zone for data fetching
  • componentDidUpdate fires after every re-render — always add a comparison guard
  • componentWillUnmount cleans up timers, subscriptions, and listeners — skip it and you leak memory
  • useEffect collapses all three phases into one API with dependency array control
  • Missing cleanup is the #1 cause of "Can't perform a React state update on an unmounted component" warnings
🚨 START HERE

Lifecycle Debug Cheat Sheet — Quick Commands for Common Scenarios

Reach for these patterns when you suspect a lifecycle issue in your React components — each scenario includes the exact fix to apply now.
🟡

Infinite re-render after setState in componentDidUpdate

Immediate ActionOpen DevTools → Components tab → check render count on the component. If it's incrementing every frame, you have a lifecycle loop.
Commands
Add a conditional guard: if (prevProps.userId !== this.props.userId) { this.fetchData(this.props.userId); }
If using hooks, verify the dependency array is correct: useEffect(() => { fetchData(userId); }, [userId]) — never omit deps for side effects.
Fix NowTemporarily comment out the setState call inside componentDidUpdate to verify the loop stops. Then add the comparison guard and uncomment.
🟡

setState on unmounted component warning

Immediate ActionRun performance.memory check in browser console to see if heap is growing. A growing heap confirms the leak.
Commands
Add console log inside componentWillUnmount to verify it fires: componentWillUnmount() { console.log('Unmounting', this.constructor.name); clearInterval(this.timer); }
For async cancels: const controller = new AbortController(); fetch(url, { signal: controller.signal }); controller.abort() in willUnmount.
Fix NowSet a component-level mounted flag: this._isMounted = true in componentDidMount, false in componentWillUnmount. Check it before any setState call in async callbacks.
🟡

componentDidUpdate not firing when props change

Immediate ActionVerify the parent is actually passing different props — add console.log('Props:', this.props) inside render to see current values.
Commands
Check if shouldComponentUpdate is defined and returning false. Add console.log('SCU:', nextProps, this.props) to debug.
Ensure you're not mutating state directly — setState({ ...state, key: newValue }) creates a new reference. Direct mutation ({ state.key = newValue }) won't trigger re-render or didUpdate.
Fix NowAdd a __DEV__ only: componentDidUpdate(prevProps) { Object.keys(this.props).forEach(key => { if (this.props[key] !== prevProps[key]) console.log(key, 'changed'); }); }
Production Incident

The 3 AM Pager: Memory Leak That Took Down a Trading Dashboard

A production dashboard that displayed real-time stock prices started consuming 2GB of memory within 30 minutes. Users reported browser tabs freezing and crashing. The root cause was a single setInterval that was never cleared.
SymptomDashboard tab consumes ~200MB immediately, climbs to 1.5GB+ within 30 minutes. Browser tab crashes or Chrome kills the tab with 'Aw, Snap!' Users on low-end machines report even faster crashes.
AssumptionThe team assumed React would automatically clean up timers because the component was managing state. They thought that since the interval called setState, and setState manages component lifecycle, React must handle the cleanup.
Root causeA setInterval was started in componentDidMount for polling stock prices every 5 seconds. Nobody added clearInterval in componentWillUnmount. When users navigated to other pages in the SPA, the component unmounted but the interval kept firing. Each tick called setState on an unmounted component, and React queued the state updates. Over time, the queued updates accumulated in memory. The browser's event loop kept the interval alive, preventing garbage collection of the component tree. Memory grew linearly with time the dashboard was open.
FixStore the interval ID as a class property (this.pollingTimer) in componentDidMount and call clearInterval(this.pollingTimer) inside componentWillUnmount. The hooks equivalent: store the interval in a ref and clear it in the useEffect cleanup function.
Key Lesson
Every resource you create outside React's managed state must be torn down explicitly.componentWillUnmount is not optional — it's a contract. If you set it up in componentDidMount, tear it down there.The 'Can't perform a React state update on an unmounted component' warning is never a false positive. It always signals a real leak.In functional components, the useEffect cleanup function is called before re-run AND on unmount — use it for both scenario logic.
Production Debug Guide

Quick symptom-to-action mapping for the three most common lifecycle failures in production

API calls fire repeatedly in an infinite loop — browser tab slows to a crawlCheck componentDidUpdate for missing comparison guard. Look for setState calls that trigger re-renders which re-enter componentDidUpdate. Add if (prevProps.id !== this.props.id) guard around the fetch logic.
Console warning: 'Can't perform a React state update on an unmounted component'Track down every componentDidMount that creates a timer, subscription, or async fetch. Verify that componentWillUnmount cancels each one. For async fetches, use AbortController. For timers, store the ID and clear it.
Data displayed is stale — shows previous query results even after props changedVerify that componentDidUpdate watches the relevant props. You likely have data fetching only in componentDidMount but no re-fetch logic when props change. Add comparison guard and re-fetch inside componentDidUpdate.
Third-party chart library doesn't render or throws 'container not found'Move the chart initialisation from constructor or render to componentDidMount. The DOM node doesn't exist in constructor, and render doesn't guarantee the ref is populated yet. componentDidMount guarantees the node is in the real DOM.

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.

LifecycleOverview.jsx · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
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;
▶ Output
[1] constructor — component instance created
[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
🔥The Order Matters:
constructor → render → componentDidMount → (state/props change) → render → componentDidUpdate → componentWillUnmount. Memorise this sequence and you'll instantly know WHY your code is or isn't running when you expect it to.
📊 Production Insight
Most devs assume constructor is the right place for data fetching because it runs first.
It runs before the DOM exists — any setState here is ignored and any DOM query fails silently.
Rule: constructor is for state initialisation only. componentDidMount is for side effects.
🎯 Key Takeaway
Lifecycle methods fire in a fixed, predictable order.
Learn the sequence once and you eliminate 80% of timing bugs.
The sequence is: constructor → render → didMount → render → didUpdate → willUnmount.

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.

WeatherDashboard.jsx · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
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&current_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;
▶ Output
// On mount:
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
⚠ Watch Out: The Async componentDidMount Trap
Marking componentDidMount as async is fine, but React doesn't await it. If the component unmounts before your async operation finishes, calling setState on the unmounted component will log a warning. The fix: use a mounted flag (this.isMounted = true in didMount, false in willUnmount) or cancel the request with an AbortController.
📊 Production Insight
The async componentDidMount pattern causes a subtle production bug.
React doesn't wait for the async function to complete before allowing unmount.
Rule: wrap async callbacks with a mounted flag check to avoid state-update-on-unmounted warnings.
🎯 Key Takeaway
componentDidMount is your guaranteed-safe zone for DOM-dependent operations.
Data fetching, subscriptions, and third-party lib initialisation belong here — not in constructor or render.
If you set it up in didMount, you MUST tear it down in willUnmount.

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]).

SalesReportTable.jsx · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
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;
▶ Output
// Initial mount:
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
💡Pro Tip: Compare Deep Objects with JSON.stringify (Carefully)
If your prop is an object or array, previousProps.filters !== this.props.filters will always be true (different references). For shallow cases, compare specific fields. For complex filters, use JSON.stringify(previousProps.filters) !== JSON.stringify(this.props.filters) — but only if the object is small and stable in structure. For production, reach for a deep-equality library or restructure your state to use primitives.
📊 Production Insight
The infinite loop from missing comparison guard is the most common lifecycle bug we debug.
A setState inside componentDidUpdate without a guard causes re-render → didUpdate → setState → re-render.
Rule: always compare previous and current values before calling setState in componentDidUpdate.
🎯 Key Takeaway
componentDidUpdate fires after every re-render — not just the one you care about.
Always wrap your logic with a conditional comparison guard.
Without it, you WILL create an infinite loop that freezes the browser tab.

componentWillUnmount — The Cleanup Phase You Can't Afford to Skip

componentWillUnmount is the most overlooked lifecycle method, and it's the one that causes the most insidious production bugs. It fires right before the component is removed from the DOM — your last chance to clean up anything that isn't managed by React's reconciliation engine.

The stuff you must clean up: timers (setInterval, setTimeout), event listeners on window or document, WebSocket or SignalR connections, third-party library instances, observer subscriptions (ResizeObserver, IntersectionObserver), and in-flight fetch requests. If you created it in componentDidMount and it's not a React-managed DOM node, it belongs in componentWillUnmount.

Here's what happens when you don't clean up: the timer or subscription keeps running, but the component it references is gone. If that callback calls setState, you get the infamous 'Can't perform a React state update on an unmounted component' warning. More importantly, you're leaking memory — the old component tree can't be garbage collected because the timer closure holds a reference to it. In a long-running SPA, components accumulate, memory grows, and eventually the browser tab crashes.

With hooks, the cleanup function returned from useEffect handles this. Every time the effect re-runs, the previous cleanup runs first. When the component unmounts, the final cleanup runs. This co-location of setup and teardown is one of the strongest arguments for functional components.

ChatSubscription.jsx · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
import React, { Component, createRef } from 'react';

// Real-world scenario: a chat panel that connects to a WebSocket
// on mount and MUST disconnect on unmount to prevent resource leaks
class ChatPanel extends Component {
  constructor(props) {
    super(props);
    this.state = {
      messages: [],
      onlineUsers: 0,
      connectionStatus: 'disconnected',
    };
    this.chatSocket = null;
    this.reconnectTimer = null;
    this.scrollRef = createRef();
  }

  componentDidMount() {
    // Connect to the chat WebSocket
    this.connectToChat(this.props.channelId);

    // Add a resize listener to adjust chat dimensions
    window.addEventListener('resize', this.handleWindowResize);
  }

  componentDidUpdate(prevProps) {
    // If the user switches to a different chat channel,
    // disconnect from old channel and connect to new one
    if (prevProps.channelId !== this.props.channelId) {
      console.log('Channel changed — reconnecting...');
      this.disconnectFromChat();
      this.connectToChat(this.props.channelId);
    }
  }

  componentWillUnmount() {
    // THREE things must be cleaned up:
    // 1. WebSocket connection
    // 2. Reconnect timer
    // 3. Window resize listener
    this.disconnectFromChat();

    // Clear any scheduled reconnect
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
      console.log('Reconnect timer cleared');
    }

    // Remove event listener — using the EXACT same reference as addEventListener
    window.removeEventListener('resize', this.handleWindowResize);
    console.log('ChatPanel unmounted — all resources released');
  }

  // Stored as a class property so we can reference it in both addEventListener
  // and removeEventListener with the exact same function reference
  handleWindowResize = () => {
    console.log('Window resized — adjusting chat dimensions');
    // Adjust chat UI dimensions
  };

  connectToChat(channelId) {
    console.log(`Connecting to chat channel: ${channelId}`);
    this.setState({ connectionStatus: 'connecting' });

    // Simulate a WebSocket connection using setInterval
    // A real implementation would use: new WebSocket(`wss://chat.example.com/${channelId}`)
    this.chatSocket = setInterval(() => {
      const mockMessages = [
        { id: Date.now(), user: 'Alice', text: 'Hey everyone!' },
        { id: Date.now() + 1, user: 'Bob', text: 'Morning!' },
      ];

      // Simulate receiving a new message
      const randomMessage = mockMessages[Math.floor(Math.random() * mockMessages.length)];

      this.setState((prevState) => ({
        messages: [...prevState.messages, { ...randomMessage, timestamp: Date.now() }],
        onlineUsers: Math.floor(Math.random() * 50) + 10,
        connectionStatus: 'connected',
      }));
    }, 4000);

    // Simulate initial connection delay
    setTimeout(() => {
      this.setState({ connectionStatus: 'connected' });
    }, 500);
  }

  disconnectFromChat() {
    if (this.chatSocket) {
      clearInterval(this.chatSocket);
      this.chatSocket = null;
      this.setState({ connectionStatus: 'disconnected' });
      console.log('Disconnected from chat');
    }
  }

  render() {
    const { messages, onlineUsers, connectionStatus } = this.state;

    return (
      <div className="chat-panel" ref={this.scrollRef}>
        <div className="chat-header">
          <span>Online users: {onlineUsers}</span>
          <span className={`connection-badge ${connectionStatus}`}>
            {connectionStatus}
          </span>
        </div>

        <div className="chat-messages">
          {messages.map((msg) => (
            <div key={msg.id} className="message-row">
              <strong>{msg.user}:</strong> {msg.text}
            </div>
          ))}
        </div>
      </div>
    );
  }
}

export default ChatPanel;
▶ Output
// On mount (channelId: 'general'):
Connecting to chat channel: general
Connecting to chat channel: general
Connection established

// After 4s: receives message from Alice
// After 8s: receives message from Bob

// If channelId changes to 'random':
Channel changed — reconnecting...
Disconnected from chat
Connecting to chat channel: random
Connection established

// On unmount:
Disconnected from chat
Reconnect timer cleared
Window resize listener removed
ChatPanel unmounted — all resources released
⚠ The Event Listener Trap
window.addEventListener and window.removeEventListener must receive the EXACT same function reference. If you use an anonymous arrow function in addEventListener, you can't reference it in removeEventListener — the listener never gets removed. Always assign the handler to a class property or a named variable.
📊 Production Insight
Memory leaks from missing cleanup compound over time in SPAs with nested routing.
Each navigation that forgets to unsubscribe leaves a dead component tree in memory.
Rule: use the Chrome DevTools Memory profiler — take a heap snapshot, navigate away, take another snapshot. If memory doesn't drop, you have a leak.
🎯 Key Takeaway
componentWillUnmount is your last chance to prevent a memory leak.
Every timer, listener, and subscription created in didMount MUST be destroyed here.
Skip it and you'll accumulate dead components until the browser tab crashes.

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.

NotificationSubscription.jsx · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
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 };
▶ Output
// Both components produce identical behaviour:

// 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
🔥Interview Gold:
When asked 'how does useEffect compare to lifecycle methods?', don't just say 'it replaces them.' Say this: 'useEffect unifies all three lifecycle phases into one API. The dependency array controls when it runs, and the return function handles cleanup. The real power is that it co-locates related setup and teardown logic, which class components force you to split across three separate methods.'
📊 Production Insight
The useEffect dependency array is the most common source of bugs in functional React.
Missing a dependency causes stale closures — the effect runs but reads old values.
Rule: the exhaustive-deps ESLint rule is not optional. Turn it on and treat its warnings as errors.
🎯 Key Takeaway
useEffect collapses mount, update, and unmount into one composable API.
The dependency array controls when it runs — empty for mount-only, populated for selective update.
The cleanup return function is your componentWillUnmount — co-located with the setup it tears down.
AspectClass Lifecycle MethodsuseEffect Hook (Functional)
SyntaxSeparate named methods (componentDidMount, etc.)Single useEffect() with config options
Mount oncecomponentDidMount()useEffect(() => {}, [])
Update on changecomponentDidUpdate(prevProps, prevState)useEffect(() => {}, [dependency])
Cleanup / unmountcomponentWillUnmount()return () => {} inside useEffect
Logic co-locationSetup and teardown split across methodsSetup and teardown in same block
Multiple concernsAll concerns mixed in one methodSeparate useEffect per concern
Learning curveExplicit and readable for beginnersMore composable, but requires mental model shift
Legacy codebase likelihoodVery common in pre-2019 codeStandard in modern React codebases
shouldComponentUpdate equivalentshouldComponentUpdate() methodReact.memo() + useMemo()
Preventing infinite loopsManual comparison guard requiredCorrect dependency array prevents most loops
Error boundary integrationcomponentDidCatch() for error handlingNo direct equivalent — use ErrorBoundary component

🎯 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.
  • The event listener reference trap is subtle: anonymous functions in addEventListener can never be removed with removeEventListener — always use a named reference.

⚠ Common Mistakes to Avoid

    Missing comparison guard in componentDidUpdate
    Symptom

    Infinite loop causes browser tab to freeze and console floods with thousands of log lines. The call stack shows repeated calls to componentDidUpdate → setState → render → componentDidUpdate.

    Fix

    Always wrap your logic with if (previousProps.someValue !== this.props.someValue) before calling setState or firing any side effect inside componentDidUpdate. Never assume componentDidUpdate fires only for the change you care about.

    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. Memory grows over time as you navigate through the app, and ghost data appears in other components because stale callbacks update state unexpectedly.

    Fix

    Store any interval, timeout, or subscription ID in a class property (this.timerID = setInterval(...)) and call clearInterval(this.timerID) inside componentWillUnmount. For event listeners, store the handler as a class property so you can pass the exact same reference to removeEventListener.

    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 because each fetch triggers a state update which triggers another render.

    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. If you need to fetch on mount, use componentDidMount. If you need to re-fetch on prop change, use componentDidUpdate with a comparison guard.

    Using an anonymous arrow function in addEventListener and trying to remove it with removeEventListener
    Symptom

    The event listener accumulates on every mount — each time the component re-mounts, another listener is added. The old ones are never removed because they reference different function instances.

    Fix

    Always store the event handler as a class property (handleResize = () => { ... }) or a named function. Use the same reference in both addEventListener('resize', this.handleResize) and removeEventListener('resize', this.handleResize).

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?JuniorReveal
    The lifecycle order is: constructor → render → componentDidMount → (updates) → render → componentDidUpdate → componentWillUnmount. I'd use componentDidMount for the initial API fetch. The constructor runs before the DOM exists, so any setState there is ignored. render runs before the DOM is mounted, so you can't safely interact with the DOM yet. componentDidMount runs after the component is painted to the screen — the DOM node exists, refs are populated, and any setState call will correctly trigger a re-render. For subsequent fetches when props change, I'd use componentDidUpdate with a comparison guard: if (prevProps.userId !== this.props.userId) { this.fetchData(this.props.userId); } In a functional component, I'd use useEffect with an empty dependency array for the initial fetch and useEffect with [userId] for the update scenario.
  • QWhat happens if you call setState inside componentDidUpdate without a conditional check? How would you debug that in production?Mid-levelReveal
    You get an infinite render loop. setState inside componentDidUpdate triggers a re-render, which triggers componentDidUpdate again, which calls setState again — this cycle continues until the browser tab freezes or the call stack overflows. To debug in production: open the browser DevTools Performance tab and record a profile. The call stack will show repeating calls to componentDidUpdate → setState → render → componentDidUpdate. The render count column in the React DevTools Components tab will increment on every frame. The fix is immediately obvious: add a conditional comparison guard. But if the app is already frozen, you need to deploy a hotfix that comments out the setState call in componentDidUpdate, then add the guard and redeploy. In the hooks equivalent, the same bug happens when you call setState inside a useEffect without specifying the correct dependency array, or when the state setter is called inside the effect body without proper conditions.
  • 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?SeniorReveal
    useEffect with a dependency array like [userId] runs after the component mounts AND after every render where userId has changed. The dependency array is essentially a built-in comparison guard — React compares the current value of each dependency to its previous value and only re-runs the effect if something changed. In a class component, componentDidUpdate receives prevProps and prevState explicitly, and you write your own comparison logic. This gives you more granular control — you can compare multiple props in one method and decide exactly what to do based on which combination changed. The key difference: useEffect has closure semantics. The effect function captures the values at the time the effect runs, which can lead to stale closures if you're not careful (the exhaustive-deps ESLint rule catches this). componentDidUpdate always receives the current instance values via this.props and this.state, so there's no stale closure risk. Another difference: useEffect runs after layout and paint by default (unless you use useLayoutEffect). componentDidUpdate runs synchronously after the DOM updates but before the browser paints — this matters if you need to read DOM measurements before paint.

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.

What's the difference between useEffect with no deps, empty deps, and populated deps?

useEffect with no dependency array at all runs after every single render — including the initial mount. This is almost never what you want and usually indicates a bug. useEffect with an empty array [] runs once after the initial mount — equivalent to componentDidMount. useEffect with a populated array [value] runs after the initial mount AND after every render where value changed — equivalent to componentDidMount + componentDidUpdate for that specific value. The cleanup function (return) runs before the next effect run and on unmount — equivalent to componentWillUnmount.

Can I use async/await in componentDidMount?

Yes, you can mark componentDidMount as async. But there's a catch: React doesn't await the async function. If the component unmounts before the async operation completes, the callback will try to call setState on an unmounted component. The fix: use an AbortController for fetch requests, or track a mounted flag (this._isMounted) that you set to false in componentWillUnmount. Check it before calling setState in the async callback.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousReact Custom HooksNext →React Error Boundaries
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged