Frontend Development

React Component Lifecycle Explained

Mayur Dabhi
Mayur Dabhi
March 14, 2026
22 min read

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.

Why Lifecycle Matters

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:

React Component Lifecycle Flow MOUNTING constructor() render() React updates DOM and refs componentDidMount() Side effects OK props/state change UPDATING shouldComponent Update() render() React updates DOM and refs componentDidUpdate() Side effects OK Re-render cycle component removed UNMOUNTING componentWill Unmount() Cleanup here Component Removed

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.

MountingExample.jsx
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;
Common Mistake

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!

UpdatingExample.jsx
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>
    );
  }
}
Infinite Loop Alert!

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.

Cleanup

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
UnmountingExample.jsx
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>
    );
  }
}
Memory Leak Prevention

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:

MountWithHooks.jsx
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>;
}
UpdateWithHooks.jsx
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>
  );
}
UnmountWithHooks.jsx
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)
CompleteLifecycleHooks.jsx
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 Execution Flow Initial Render Component Renders React updates DOM Browser paints screen useEffect runs (setup function) state/props change Re-render Component Re-renders React updates DOM Browser paints screen Cleanup runs (previous effect) useEffect runs (new setup) unmount Unmount Cleanup runs (final cleanup) Component removed Legend: Setup (effect runs) Cleanup (return function) React internal work Effects run AFTER render and paint. They don't block the browser!

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:

DataFetchingPattern.jsx
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

EventListenerPattern.jsx
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

TimerPattern.jsx
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
Key Takeaways
  • 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.

React Components Lifecycle useEffect Hooks JavaScript
Mayur Dabhi

Mayur Dabhi

Full Stack Developer passionate about building scalable web applications with modern technologies. I love sharing knowledge and helping developers grow.