Frontend Development

React Component Lifecycle Explained

Mayur Dabhi
Mayur Dabhi
March 14, 2026
22 min read

You add a useEffect that fetches some data, open the Network tab, and see the request fire twice. Or a search box shows results for the previous query because an old request finished last. Or the console fills up with errors from a WebSocket that kept running after you navigated away. Almost every one of these bugs comes down to code running at the wrong point in the component lifecycle.

This post walks through the three phases (mounting, updating, unmounting), first with class lifecycle methods because they make each step explicit, then with hooks, which is how you'll write most new code. After that come the patterns I reach for most and the mistakes that cause the bugs above.

What knowing the lifecycle buys you

In practice it helps with four things:

  • Side effects: fetching data, setting up subscriptions and touching the DOM at a point where it's safe
  • Performance: skipping renders you don't need and releasing resources you're done with
  • Debugging: figuring out why an effect ran, didn't run, or ran twice
  • Structure: putting logic where React expects it instead of fighting the render model

The Three Phases of Component Lifecycle

A component's life has three parts:

Mounting

Component is created and inserted into the DOM for the first time

Updating

Component re-renders due to changes in props or state

Unmounting

Component is removed from the DOM and cleaned up

Here's how they connect for a class component:

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

Mounting happens once per component instance: React creates it, renders it, and puts the result in the DOM. If the component is removed and shown again later, that's a brand new mount with fresh state. For class components, React calls these methods in order:

constructor(props) ●

Called first when the component is created. Use it to initialize state and bind methods. Always call super(props) first. Avoid side effects here.

static getDerivedStateFromProps(props, state) ●

Rarely needed. Called right before rendering. Returns an object to update state, or null. Only for the unusual case where state has to follow props over time.

render() ●

The only required method. Returns JSX, arrays, fragments, portals, strings, numbers, or null. It has to be pure: no side effects and no direct DOM manipulation.

componentDidMount() ●

This is where side effects go. It runs right after the component is in the DOM, so it's the place for API calls, subscriptions, DOM measurements and timers.

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

Don't call setState() in the constructor (assign this.state directly) and don't make API calls there. The component isn't mounted yet, so there's no DOM to work with. Anything that needs the DOM or causes a side effect belongs in componentDidMount().

Phase 2: Updating

Any time props or state change, React re-renders the component and its children with the new data. Unlike mounting, this can happen hundreds of times while the component is on screen, so whatever you put here runs a lot.

The methods called during an update, in order:

static getDerivedStateFromProps(props, state) ●

Called first on every update. Works the same as during mounting: return an object to update state, or null.

shouldComponentUpdate(nextProps, nextState) ●

A performance escape hatch. Return false to skip the re-render (the default is true). Compare current and next props/state to decide.

render() ●

Re-renders with the new props/state. Still has to be pure: the same inputs should give the same output.

getSnapshotBeforeUpdate(prevProps, prevState) ●

Called right before DOM updates. Capture scroll position, element dimensions, etc. Return value is passed to componentDidUpdate.

componentDidUpdate(prevProps, prevState, snapshot) ●

Called after the re-render. Compare previous and current props/state here. Side effects are fine, but wrap them in a condition or you'll get an infinite loop.

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>
    );
  }
}

One thing to notice in that example: shouldComponentUpdate only checks query and results. When fetchResults sets loading: true, neither has changed, so the render is skipped and "Searching..." never shows up. That's the usual trap with hand-written shouldComponentUpdate: forget one field and part of your UI silently stops updating. PureComponent (a shallow compare of all props and state) is the safer default.

Watch for infinite loops

Calling setState() in componentDidUpdate without a condition triggers another update, which calls componentDidUpdate again, and so on. React eventually stops it with "Maximum update depth exceeded". Wrap the update in a check:

// ❌ BAD - Infinite loop!
componentDidUpdate() {
  this.setState({ updated: true });
}

// ✅ GOOD - Conditional update
componentDidUpdate(prevProps) {
  if (prevProps.id !== this.props.id) {
    this.setState({ updated: true });
  }
}

Phase 3: Unmounting

When a component is about to leave the DOM, you get one last call to release whatever it set up. Anything you skip here keeps running in the background with no component attached to it.

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

Skipping cleanup in componentWillUnmount() leaks memory: open sockets, intervals and listeners that hold references to a dead component. On React 17 and older you'd see "Can't perform a React state update on an unmounted component". React 18 removed that warning, so on newer versions the leak is silent and you have to find it yourself (the Memory tab in DevTools, or a log line in your cleanup).

Modern Approach: Hooks Lifecycle

Function components don't have lifecycle methods. You describe effects with useEffect and React decides when to run them based on the dependency array. The mapping isn't exact (an effect is closer to "keep this in sync with these values" than to a lifecycle event), but it's a good starting point:

Class Lifecycle Method Hooks Equivalent
constructor useState initialization
componentDidMount useEffect(() => {}, [])
componentDidUpdate useEffect(() => {}, [deps])
componentWillUnmount useEffect cleanup function
shouldComponentUpdate React.memo + useMemo

Here's each phase written with hooks:

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

Why does my effect run twice?

In development, React 18+ with <StrictMode> mounts every component, immediately unmounts it, and mounts it again. So a useEffect(fn, []) runs setup, cleanup, setup, and you'll see two requests in the Network tab. This only happens in dev builds. Don't "fix" it with a useRef flag; it's there to expose effects whose cleanup is missing or broken. If your component behaves correctly after the second mount, you're fine.

Best Practices & Common Patterns

1. Data Fetching Pattern

Fetching data is the effect you'll write most. This version handles errors, aborts the request when the URL changes or the component unmounts, and ignores late responses:

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;
  };
}, []);

Lifecycle Cheatsheet

Keep this table handy when you're porting a class component to hooks or reading older code:

Quick Reference

When Class Method Hook Pattern
Initialize state constructor() useState(initialValue)
After first render componentDidMount() useEffect(fn, [])
After every render componentDidUpdate() useEffect(fn)
When deps change componentDidUpdate() + check useEffect(fn, [deps])
Before unmount componentWillUnmount() useEffect(() => cleanup, [])
Optimize renders shouldComponentUpdate() React.memo() + useMemo

When I'd still use class lifecycle methods (and when I'd skip useEffect)

For new code I write function components and hooks. Classes still have a couple of jobs hooks can't do:

Where classes are still needed
  • Error boundaries: componentDidCatch and static getDerivedStateFromError have no hook equivalent. You either write one small class or use a package like react-error-boundary, which is a class under the hood.
  • getSnapshotBeforeUpdate: also has no hook version. useLayoutEffect covers most scroll-restoring cases, but it runs after the DOM change, so you have to keep the "before" value in a ref yourself.
  • Existing class code that works: I wouldn't rewrite it just to use hooks. Convert a component when you're already changing it for another reason.

The other half is knowing when an effect is the wrong tool. If you're computing a value from props or state (filtering a list, building a full name), do it during render, maybe with useMemo. Copying it into state through an effect causes an extra render and a frame where the two are out of sync. If something should happen because the user clicked a button, put it in the click handler, not in an effect watching a flag. And for real data fetching in a production app, the hand-rolled useDataFetch above gets you started, but it has no caching, deduping or retries. A library like TanStack Query or SWR, or your framework's loaders, handles those for you.

When something does belong in an effect, the questions are always the same: what does it set up, what values does it depend on, and what undoes it? If you can answer all three, the lifecycle stops being the source of your bugs.

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.