DailyDevDiet

logo - dailydevdiet

Learn. Build. Innovate. Elevate your coding skills with dailydevdiet!

Chapter 8: Lifecycle Methods

Lifecycle Methods

Introduction to Component Lifecycle

Every React component goes through a series of phases from the moment it’s created to when it’s removed from the DOM. These phases are collectively known as the component lifecycle.

Understanding lifecycle methods allows you to:

  • Control component behavior at specific points in time
  • Perform operations like data fetching at the right moment
  • Clean up resources to prevent memory leaks
  • Optimize performance by avoiding unnecessary renders

Class Component Lifecycle Methods

Traditional React class components have specific methods that execute at different stages of a component’s lifecycle. These methods can be grouped into three main phases:

  1. Mounting – When a component is being created and inserted into the DOM
  2. Updating – When a component is being re-rendered due to changes in props or state
  3. Unmounting – When a component is being removed from the DOM

Let’s explore each phase and its associated methods.

Mounting Phase

The mounting phase occurs when a component is being initialized and inserted into the DOM. The methods execute in the following order:

1. constructor()

The constructor is the first method called when a component is initialized. It’s used for:

  • Initializing state
  • Binding event handler methods
  • Setting up initial values
class Counter extends React.Component {
  constructor(props) {
    super(props); // Always call super(props) first
   
    // Initialize state
    this.state = {
      count: 0
    };
   
    // Bind event handlers
    this.handleIncrement = this.handleIncrement.bind(this);
  }
 
  handleIncrement() {
    this.setState({ count: this.state.count + 1 });
  }
 
  render() {
    return (
      <div>
        <p>Count: {this.state.count}</p>
        <button onClick={this.handleIncrement}>Increment</button>
      </div>
    );
  }
}

Best Practices:

  • Always call super(props) first in the constructor
  • Use the constructor for initialization, not for side effects
  • Consider using class fields instead of the constructor for simpler components

2. static getDerivedStateFromProps()

This static method is invoked right before calling the render method, both on the initial mount and on subsequent updates. It returns an object to update the state or null to indicate no state update is necessary.

class ExampleComponent extends React.Component {
  state = {
    derivedData: null
  };
 
  static getDerivedStateFromProps(props, state) {
    // Calculate derived state based on props
    if (props.data !== state.prevData) {
      return {
        derivedData: processData(props.data),
        prevData: props.data
      };
    }
   
    // Return null to indicate no state update needed
    return null;
  }
 
  render() {
    return <div>{this.state.derivedData}</div>;
  }
}

Best Practices:

  • Use sparingly – most components don’t need derived state
  • Don’t cause side effects here (no API calls, etc.)
  • Use for rare cases when state depends on props changes

3. render()

The render method is the only required method in a class component. It should:

  • Examine props and state
  • Return React elements, arrays, strings, numbers, booleans, or null
  • Not modify the component state
  • Not interact with the browser directly
class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

Best Practices:

  • Keep render methods pure (no side effects)
  • Extract complex render logic into smaller components
  • Use conditional rendering for different states

4. componentDidMount()

This method is invoked immediately after a component is mounted (inserted into the DOM). This is the ideal place for:

  • Data fetching
  • DOM manipulation
  • Setting up subscriptions
class UserProfile extends React.Component {
  state = {
    user: null,
    loading: true,
    error: null
  };
 
  componentDidMount() {
    // Fetch user data from API
    fetch(`https://api.example.com/users/${this.props.userId}`)
      .then(response => response.json())
      .then(data => {
        this.setState({
          user: data,
          loading: false
        });
      })
      .catch(error => {
        this.setState({
          error,
          loading: false
        });
      });
     
    // Set up event listeners or subscriptions
    window.addEventListener('resize', this.handleResize);
  }
 
  handleResize = () => {
    this.setState({ windowWidth: window.innerWidth });
  };
 
  componentWillUnmount() {
    // Clean up subscriptions
    window.removeEventListener('resize', this.handleResize);
  }
 
  render() {
    const { user, loading, error } = this.state;
   
    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    if (!user) return <div>No user found</div>;
   
    return (
      <div>
        <h1>{user.name}</h1>
        <p>Email: {user.email}</p>
      </div>
    );
  }
}class UserProfile extends React.Component {
  state = {
    user: null,
    loading: true,
    error: null
  };
 
  componentDidMount() {
    // Fetch user data from API
    fetch(`https://api.example.com/users/${this.props.userId}`)
      .then(response => response.json())
      .then(data => {
        this.setState({
          user: data,
          loading: false
        });
      })
      .catch(error => {
        this.setState({
          error,
          loading: false
        });
      });
     
    // Set up event listeners or subscriptions
    window.addEventListener('resize', this.handleResize);
  }
 
  handleResize = () => {
    this.setState({ windowWidth: window.innerWidth });
  };
 
  componentWillUnmount() {
    // Clean up subscriptions
    window.removeEventListener('resize', this.handleResize);
  }
 
  render() {
    const { user, loading, error } = this.state;
   
    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    if (!user) return <div>No user found</div>;
   
    return (
      <div>
        <h1>{user.name}</h1>
        <p>Email: {user.email}</p>
      </div>
    );
  }
}class UserProfile extends React.Component {
  state = {
    user: null,
    loading: true,
    error: null
  };
 
  componentDidMount() {
    // Fetch user data from API
    fetch(`https://api.example.com/users/${this.props.userId}`)
      .then(response => response.json())
      .then(data => {
        this.setState({
          user: data,
          loading: false
        });
      })
      .catch(error => {
        this.setState({
          error,
          loading: false
        });
      });
     
    // Set up event listeners or subscriptions
    window.addEventListener('resize', this.handleResize);
  }
 
  handleResize = () => {
    this.setState({ windowWidth: window.innerWidth });
  };
 
  componentWillUnmount() {
    // Clean up subscriptions
    window.removeEventListener('resize', this.handleResize);
  }
 
  render() {
    const { user, loading, error } = this.state;
   
    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    if (!user) return <div>No user found</div>;
   
    return (
      <div>
        <h1>{user.name}</h1>
        <p>Email: {user.email}</p>
      </div>
    );
  }
}

Best Practices:

  • Great place for initialization that requires DOM nodes
  • Remember to clean up subscriptions in componentWillUnmount
  • Can call setState here, but use sparingly to avoid extra rendering

Updating Phase

The updating phase occurs when a component’s props or state change, causing it to re-render. The methods execute in the following order:

1. static getDerivedStateFromProps()

As explained earlier, this method is also called during updates, before render.

2. shouldComponentUpdate()

This method lets you decide whether a component’s render method should be called when state or props change. By default, it returns true, but you can override it for performance optimization.

class UserProfile extends React.Component {
  state = {
    user: null,
    loading: true,
    error: null
  };
 
  componentDidMount() {
    // Fetch user data from API
    fetch(`https://api.example.com/users/${this.props.userId}`)
      .then(response => response.json())
      .then(data => {
        this.setState({
          user: data,
          loading: false
        });
      })
      .catch(error => {
        this.setState({
          error,
          loading: false
        });
      });
     
    // Set up event listeners or subscriptions
    window.addEventListener('resize', this.handleResize);
  }
 
  handleResize = () => {
    this.setState({ windowWidth: window.innerWidth });
  };
 
  componentWillUnmount() {
    // Clean up subscriptions
    window.removeEventListener('resize', this.handleResize);
  }
 
  render() {
    const { user, loading, error } = this.state;
   
    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;
    if (!user) return <div>No user found</div>;
   
    return (
      <div>
        <h1>{user.name}</h1>
        <p>Email: {user.email}</p>
      </div>
    );
  }
}
    return <div>{this.props.value}</div>;
  }
}

// Helper function for shallow comparison
function shallowEqual(obj1, obj2) {
  const keys1 = Object.keys(obj1);
  const keys2 = Object.keys(obj2);
 
  if (keys1.length !== keys2.length) return false;
 
  for (let key of keys1) {
    if (obj1[key] !== obj2[key]) return false;
  }
 
  return true;
}

Best Practices:

  • Use for performance optimization on components that render often
  • Implement shallow comparisons of props and state
  • Consider using React.PureComponent instead of writing this manually
  • Be careful not to block necessary updates

3. render()

The render method is called again to determine what should be displayed.

4. getSnapshotBeforeUpdate()

This method is called right before the most recently rendered output is committed to the DOM. It allows your component to capture some information from the DOM before it’s potentially changed.

class ScrollingList extends React.Component {
  constructor(props) {
    super(props);
    this.listRef = React.createRef();
  }
 
  getSnapshotBeforeUpdate(prevProps, prevState) {
    // Capture the scroll position before the update
    if (prevProps.list.length < this.props.list.length) {
      const list = this.listRef.current;
      return list.scrollHeight - list.scrollTop;
    }
    return null;
  }
 
  componentDidUpdate(prevProps, prevState, snapshot) {
    // If we have a snapshot value, maintain scroll position
    if (snapshot !== null) {
      const list = this.listRef.current;
      list.scrollTop = list.scrollHeight - snapshot;
    }
  }
 
  render() {
    return (
      <div ref={this.listRef} style={{ height: '200px', overflow: 'auto' }}>
        {this.props.list.map((item, index) => (
          <div key={index}>{item}</div>
        ))}
      </div>
    );
  }
}

Best Practices:

  • Use for cases where you need DOM information before update
  • The value returned becomes the third parameter of componentDidUpdate
  • Relatively rare – most components don’t need this method

5. componentDidUpdate()

This method is called immediately after updating occurs. It is not called for the initial render.

class DataFetcher extends React.Component {
  state = {
    data: null,
    loading: true
  };
 
  componentDidMount() {
    this.fetchData(this.props.id);
  }
 
  componentDidUpdate(prevProps) {
    // Re-fetch data when props change
    if (prevProps.id !== this.props.id) {
      this.fetchData(this.props.id);
    }
  }
 
  fetchData(id) {
    this.setState({ loading: true });
   
    fetch(`https://api.example.com/data/${id}`)
      .then(response => response.json())
      .then(data => {
        this.setState({
          data,
          loading: false
        });
      });
  }
 
  render() {
    const { data, loading } = this.state;
   
    if (loading) return <div>Loading...</div>;
   
    return <div>{JSON.stringify(data)}</div>;
  }
}

Best Practices:

  • Good place to operate on the DOM after an update
  • Good for network requests based on prop changes
  • Be careful with setState – always wrap in a condition to avoid infinite loops

Unmounting Phase

The unmounting phase occurs when a component is being removed from the DOM.

componentWillUnmount()

This method is invoked immediately before a component is unmounted and destroyed. It’s the place for cleanup operations.

class Timer extends React.Component {
  state = {
    count: 0
  };
 
  componentDidMount() {
    this.timerID = setInterval(() => {
      this.setState({ count: this.state.count + 1 });
    }, 1000);
  }
 
  componentWillUnmount() {
    // Clean up the timer to prevent memory leaks
    clearInterval(this.timerID);
  }
 
  render() {
    return <div>Count: {this.state.count}</div>;
  }
}

Best Practices:

  • Always clean up subscriptions, timers, event listeners
  • Cancel pending API requests if needed
  • Don’t call setState – the component is being destroyed

Error Handling

React 16 introduced error boundary components that can catch JavaScript errors in their child component tree.

static getDerivedStateFromError()

This lifecycle is invoked after an error has been thrown by a descendant component. It receives the error and should return a value to update state.

class ErrorBoundary extends React.Component {
  state = {
    hasError: false,
    error: null
  };
 
  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI
    return {
      hasError: true,
      error
    };
  }
 
  render() {
    if (this.state.hasError) {
      return (
        <div className="error-boundary">
          <h2>Something went wrong.</h2>
          <details>
            <summary>Error Details</summary>
            {this.state.error && this.state.error.toString()}
          </details>
        </div>
      );
    }
   
    return this.props.children;
  }
}

componentDidCatch()

This method is called during the “commit” phase, so side effects are allowed. It’s used for logging errors.

class ErrorBoundary extends React.Component {
  state = {
    hasError: false,
    error: null
  };
 
  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
 
  componentDidCatch(error, errorInfo) {
    // Log the error to an error reporting service
    console.error("Error caught by boundary:", error);
    console.error("Component stack:", errorInfo.componentStack);
   
    // You could also send to a reporting service
    // logErrorToService(error, errorInfo);
  }
 
  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
   
    return this.props.children;
  }
}

// Usage
function App() {
  return (
    <ErrorBoundary>
      <MyComponent />
    </ErrorBoundary>
  );
}

Lifecycle Methods Diagram

Here’s a simplified diagram of the component lifecycle:

Mounting:
  constructor()
  static getDerivedStateFromProps()
  render()
  componentDidMount()

Updating:
  static getDerivedStateFromProps()
  shouldComponentUpdate()
  render()
  getSnapshotBeforeUpdate()
  componentDidUpdate()

Unmounting:
  componentWillUnmount()

Error Handling:
  static getDerivedStateFromError()
  componentDidCatch()

React Hooks: The Modern Alternative

While lifecycle methods are important to understand, React’s function components with Hooks provide a more modern and flexible way to achieve the same functionality. Here’s a quick comparison:

Class Lifecycle MethodHook Alternative
constructor()useState()
componentDidMount()useEffect() with empty dependency array
componentDidUpdate()useEffect() with dependencies
componentWillUnmount()useEffect() cleanup function
shouldComponentUpdate()React.memo() + useMemo/useCallback
getDerivedStateFromProps()useState() + useEffect()
getSnapshotBeforeUpdate()No direct equivalent, need custom implementation
componentDidCatch()No direct equivalent (until ErrorBoundary hooks)

Example of implementing lifecycle behavior with hooks:

import React, { useState, useEffect, useRef } from 'react';

function ProfileWithHooks({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const prevUserIdRef = useRef();
 
  // Similar to componentDidMount and componentDidUpdate
  useEffect(() => {
    // Store current userId in ref for comparison next time
    prevUserIdRef.current = userId;
   
    setLoading(true);
   
    fetch(`https://api.example.com/users/${userId}`)
      .then(response => response.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
     
    // Similar to componentWillUnmount
    return () => {
      // Cleanup operations here
      console.log("Component is unmounting or userId is changing");
    };
  }, [userId]); // Only re-run effect if userId changes
 
  // Similar to componentDidUpdate for specific prop change
  useEffect(() => {
    if (prevUserIdRef.current !== userId && prevUserIdRef.current) {
      console.log("userId changed from", prevUserIdRef.current, "to", userId);
    }
  });
 
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>No user found</div>;
 
  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

Practical Example: Data Loading Component

Let’s build a component that demonstrates the practical use of lifecycle methods to handle data loading, error states, and cleanup:

import React, { Component } from 'react';

class DataLoader extends Component {
  constructor(props) {
    super(props);
   
    this.state = {
      data: null,
      loading: true,
      error: null,
      pageNumber: 1
    };
   
    this.controller = null; // Will hold AbortController
  }
 
  fetchData = async () => {
    const { endpoint } = this.props;
    const { pageNumber } = this.state;
   
    // Clean up previous request if it exists
    if (this.controller) {
      this.controller.abort();
    }
   
    // Create new abort controller for this request
    this.controller = new AbortController();
   
    try {
      this.setState({ loading: true });
     
      const response = await fetch(
        `${endpoint}?page=${pageNumber}`,
        { signal: this.controller.signal }
      );
     
      if (!response.ok) {
        throw new Error(`HTTP error: ${response.status}`);
      }
     
      const data = await response.json();
     
      this.setState({
        data,
        loading: false,
        error: null
      });
    } catch (error) {
      // Ignore abort errors as they're expected
      if (error.name !== 'AbortError') {
        this.setState({
          error,
          loading: false
        });
      }
    }
  };
 
  handleNextPage = () => {
    this.setState(
      prevState => ({ pageNumber: prevState.pageNumber + 1 }),
      this.fetchData
    );
  };
 
  handlePrevPage = () => {
    this.setState(
      prevState => ({
        pageNumber: Math.max(1, prevState.pageNumber - 1)
      }),
      this.fetchData
    );
  };
 
  componentDidMount() {
    this.fetchData();
  }
 
  componentDidUpdate(prevProps) {
    // Re-fetch when endpoint prop changes
    if (prevProps.endpoint !== this.props.endpoint) {
      this.setState({ pageNumber: 1 }, this.fetchData);
    }
  }
 
  componentWillUnmount() {
    // Cancel any in-flight requests
    if (this.controller) {
      this.controller.abort();
    }
  }
 
  render() {
    const { data, loading, error, pageNumber } = this.state;
    const { renderItem } = this.props;
   
    return (
      <div className="data-loader">
        {loading && <div className="loader">Loading...</div>}
       
        {error && (
          <div className="error-message">
            Error: {error.message}
            <button onClick={this.fetchData}>Retry</button>
          </div>
        )}
       
        {data && !loading && !error && (
          <div className="data-container">
            <div className="items-list">
              {Array.isArray(data.items) ? (
                data.items.map(item => renderItem(item))
              ) : (
                <p>No items to display</p>
              )}
            </div>
           
            <div className="pagination">
              <button
                onClick={this.handlePrevPage}
                disabled={pageNumber === 1}
              >
                Previous
              </button>
              <span>Page {pageNumber}</span>
              <button
                onClick={this.handleNextPage}
                disabled={!data.hasMore}
              >
                Next
              </button>
            </div>
          </div>
        )}
      </div>
    );
  }
}

// Usage example
function App() {
  const renderUserItem = (user) => (
    <div key={user.id} className="user-card">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
 
  return (
    <div className="app">
      <h1>Users Directory</h1>
      <DataLoader
        endpoint="https://api.example.com/users"
        renderItem={renderUserItem}
      />
    </div>
  );
}

This example demonstrates:

  • Constructor initialization
  • Data fetching in componentDidMount
  • Refetching when props change in componentDidUpdate
  • Cleanup in componentWillUnmount
  • Conditional rendering based on different states
  • Error handling
  • Pagination logic

Common Anti-patterns and Best Practices

Anti-patterns to Avoid

  1. Calling setState in componentWillUnmount
// BAD
componentWillUnmount() {
  this.setState({ active: false }); // Component is going away, no need to update state
}
  1. Missing dependency array in useEffect (hooks equivalent)
// BAD
useEffect(() => {
  fetchData();
}); // No dependency array means this runs after every render

// GOOD
useEffect(() => {
  fetchData();
}, []); // Empty array means run once after initial render
  1. Forgetting to clean up subscriptions
// BAD
componentDidMount() {
  this.interval = setInterval(this.tick, 1000);
  document.addEventListener('click', this.handleClick);
}

// No componentWillUnmount cleanup

// GOOD
componentDidMount() {
  this.interval = setInterval(this.tick, 1000);
  document.addEventListener('click', this.handleClick);
}

componentWillUnmount() {
  clearInterval(this.interval);
  document.removeEventListener('click', this.handleClick);
}
  1. Direct DOM manipulation in lifecycle methods
// BAD
componentDidMount() {
  document.getElementById('my-element').style.color = 'red';
}

// GOOD - Use refs instead
componentDidMount() {
  if (this.myElement) {
    this.myElement.style.color = 'red';
  }
}

render() {
  return <div ref={el => this.myElement = el}>Text</div>;
}

Best Practices

  1. Keep components small and focused
    • Split complex components into smaller ones
    • Consider custom hooks for complex logic
  2. Use PureComponent or shouldComponentUpdate wisely
    • Optimize rendering performance only when needed
    • Ensure proper comparisons
  3. Move complex initialization logic to componentDidMount
    • Keep the constructor simple
    • Fetch data after the component mounts
  4. Always handle loading and error states
    • Provide feedback to users during async operations
    • Have fallbacks for error scenarios
  5. Safely handle asynchronous setState calls
    • Remember setState may be asynchronous
    • Use the function form when updates depend on previous state

Summary

In this chapter, we’ve covered:

  • The component lifecycle phases: mounting, updating, and unmounting
  • Each lifecycle method, its purpose, and common use cases
  • Error handling with componentDidCatch and error boundaries
  • The relationship between class lifecycle methods and React Hooks
  • A practical example of a data loading component
  • Common anti-patterns and best practices

Understanding component lifecycle methods gives you precise control over your components’ behavior at different stages of their existence. Although React Hooks are increasingly becoming the preferred way to handle component logic, the concepts behind the lifecycle are still fundamental to React development.

In the next chapter, we’ll explore event handling in React and how to create interactive components.

Exercises

  1. Create a class component that logs messages to the console during each lifecycle phase
  2. Build a data fetching component that loads user data and handles loading/error states
  3. Implement an error boundary component and demonstrate its functionality
  4. Create a component that uses getSnapshotBeforeUpdate to maintain scroll position
  5. Refactor a class component with lifecycle methods into a function component with hooks

Additional Resources

Scroll to Top