Refactoring Lifecycle Methods

From Class Components to Function Components with useEffect

Understanding Component Lifecycles

React components go through a series of phases during their existence - like the lifecycle of any living organism. They're born (mounted), grow and change (update), and eventually die (unmount). Class components provide special methods that run during these phases, allowing you to hook into different moments in a component's life.

Think of lifecycle methods as event handlers for a component's life events. Just as you might have celebrations for important milestones in human life (birth, graduation, marriage), React components have special functions that execute at important moments in their existence.

The Three Main Lifecycle Phases

Mounting (Birth)

This is when a component is being created and inserted into the DOM for the first time. It's like the birth of the component.

componentDidMount() runs after the component has been rendered to the screen for the first time. This is perfect for initial setup work like:

  • Fetching data from an API
  • Setting up subscriptions or timers
  • Manipulating the DOM directly (though this should be rare)

Updating (Growth)

This occurs whenever a component updates due to changes in props or state. It's like the growing and changing phase of the component.

componentDidUpdate(prevProps, prevState) runs after the component has updated. This method receives the previous props and state as arguments, allowing you to compare with current values. It's useful for:

  • Responding to prop changes
  • Fetching new data when a dependency changes
  • DOM manipulations based on state changes

Unmounting (Death)

This happens when a component is being removed from the DOM. It's the end of the component's life.

componentWillUnmount() runs right before the component is removed. This is your last chance to clean up, such as:

  • Clearing timers or intervals
  • Canceling network requests
  • Removing event listeners
  • Unsubscribing from external data sources

The Challenge: Moving from Class to Function Components

Modern React encourages the use of function components with hooks instead of class components. But how do you migrate all this lifecycle logic? Enter the useEffect hook - React's Swiss Army knife for handling side effects and lifecycle events in function components.

The useEffect hook serves as a replacement for all three lifecycle methods mentioned above, depending on how you configure it. Think of it as a chameleon that can adapt to mimic any lifecycle behavior you need.

Examining a Class Component with Lifecycle Methods

Let's start by examining a React class component that uses all three main lifecycle methods:

import React from 'react';

class TimerComponent extends React.Component {
  constructor(props) {
    super(props); // must be called if creating a constructor method

    // Initialize the component state object
    this.state = {
      count: 0,
      lastUpdated: null
    };
  }

  componentDidMount() {
    console.log('Component mounted!');
    // Set count to 5 after 5 seconds
    this.timerId = setTimeout(() => {
      this.setState({ count: 5, lastUpdated: new Date() });
    }, 5000);
    
    // Add an event listener
    window.addEventListener('resize', this.handleResize);
  }

  componentDidUpdate(prevProps, prevState) {
    // Only log when count changes
    if (prevState.count !== this.state.count) {
      console.log(`Count changed to: ${this.state.count}`);
      this.setState({ lastUpdated: new Date() });
    }
  }

  componentWillUnmount() {
    console.log('Component unmounting, cleaning up...');
    // Clear the timeout to prevent memory leaks
    clearTimeout(this.timerId);
    // Remove event listener
    window.removeEventListener('resize', this.handleResize);
  }
  
  handleResize = () => {
    console.log('Window resized');
  }

  render() {
    return (
      <div className="timer-container">
        <h2>{this.props.title || 'Timer'}</h2>
        <div className="count-display">{this.state.count}</div>
        <button onClick={() => this.setState((state) => ({ 
          count: state.count + 1 
        }))}>
          Increment
        </button>
        {this.state.lastUpdated && (
          <p>Last updated: {this.state.lastUpdated.toLocaleTimeString()}</p>
        )}
      </div>
    );
  }
}

This component demonstrates several common lifecycle patterns:

Converting to a Function Component with useEffect

Now let's transform this class component into a function component using the useEffect hook to handle all the lifecycle behaviors:

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

function TimerComponent({ title = 'Timer' }) {
  // Replace this.state with useState hooks
  const [count, setCount] = useState(0);
  const [lastUpdated, setLastUpdated] = useState(null);

  // Effect for componentDidMount and componentWillUnmount
  useEffect(() => {
    console.log('Component mounted!');
    
    // Set count to 5 after 5 seconds (equivalent to componentDidMount)
    const timerId = setTimeout(() => {
      setCount(5);
      setLastUpdated(new Date());
    }, 5000);
    
    // Add an event listener (also part of componentDidMount)
    const handleResize = () => {
      console.log('Window resized');
    };
    
    window.addEventListener('resize', handleResize);
    
    // Cleanup function (equivalent to componentWillUnmount)
    return () => {
      console.log('Component unmounting, cleaning up...');
      clearTimeout(timerId);
      window.removeEventListener('resize', handleResize);
    };
  }, []); // Empty dependency array means this runs once on mount
  
  // Effect for componentDidUpdate - runs when count changes
  useEffect(() => {
    // Skip the initial render
    if (count !== 0) {
      console.log(`Count changed to: ${count}`);
      setLastUpdated(new Date());
    }
  }, [count]); // This effect depends on count
  
  return (
    <div className="timer-container">
      <h2>{title}</h2>
      <div className="count-display">{count}</div>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>
        Increment
      </button>
      {lastUpdated && (
        <p>Last updated: {lastUpdated.toLocaleTimeString()}</p>
      )}
    </div>
  );
}

This function component achieves the same functionality as the class component but uses React hooks instead.

Understanding the useEffect Hook

The useEffect hook is your gateway to lifecycle behaviors in function components. Think of it as a Swiss Army knife with different configurations for different lifecycle scenarios.

Anatomy of useEffect

useEffect(() => {
  // Effect code (runs after render)
  
  return () => {
    // Cleanup code (runs before next effect or unmount)
  };
}, [dependencies]); // Optional dependency array

The useEffect hook takes two arguments:

  1. A function containing the effect code
  2. An optional array of dependencies

The dependency array controls when the effect runs, creating different lifecycle behaviors:

Dependency Array Equivalent Lifecycle Method When It Runs
[] (empty array) componentDidMount + componentWillUnmount Once after first render + cleanup on unmount
[dep1, dep2] (with dependencies) componentDidUpdate (with conditions) After first render and whenever any dependency changes
No dependency array componentDidMount + componentDidUpdate After every render

Mapping Lifecycle Methods to useEffect

componentDidMount → useEffect with Empty Dependency Array

To run code only once after the first render:

useEffect(() => {
  // This runs once after the initial render
  console.log('Component mounted');
  fetchInitialData();
}, []); // Empty dependency array is key!

Real-world example: Initial API fetch

useEffect(() => {
  // Fetch user data when component first mounts
  async function fetchUserProfile() {
    const response = await fetch(`/api/users/${userId}`);
    const data = await response.json();
    setUserProfile(data);
    setIsLoading(false);
  }
  
  fetchUserProfile();
}, []);

componentDidUpdate → useEffect with Dependencies

To run code when specific values change:

useEffect(() => {
  // This runs when count changes
  console.log(`Count is now: ${count}`);
  saveToLocalStorage(count);
}, [count]); // Only re-run if count changes

Real-world example: Dependent data fetching

useEffect(() => {
  // When selected user changes, fetch their posts
  if (selectedUserId) {
    async function fetchUserPosts() {
      setPostsLoading(true);
      const response = await fetch(`/api/users/${selectedUserId}/posts`);
      const data = await response.json();
      setPosts(data);
      setPostsLoading(false);
    }
    
    fetchUserPosts();
  }
}, [selectedUserId]); // Re-run when user selection changes

componentWillUnmount → useEffect Cleanup Function

To run cleanup code when a component unmounts:

useEffect(() => {
  // Setup code
  const subscription = subscribeToData();
  
  // Return a cleanup function
  return () => {
    // This runs right before the component unmounts
    subscription.unsubscribe();
    console.log('Cleaned up subscription');
  };
}, []);

Real-world example: WebSocket connection

useEffect(() => {
  // Create WebSocket connection when component mounts
  const socket = new WebSocket('wss://example.com/socket');
  
  socket.addEventListener('message', handleNewMessage);
  socket.addEventListener('error', handleSocketError);
  
  // Clean up the connection when component unmounts
  return () => {
    socket.removeEventListener('message', handleNewMessage);
    socket.removeEventListener('error', handleSocketError);
    socket.close();
    console.log('WebSocket connection closed');
  };
}, []);

Common Refactoring Patterns and Gotchas

Pattern: Multiple Effects vs. One Big Effect

In class components, related code often gets split across different lifecycle methods. With useEffect, you can organize effects by concern rather than by lifecycle.

Class approach (split by lifecycle)

componentDidMount() {
  // Data fetching
  this.fetchUserData();
  // Event listeners
  window.addEventListener('resize', this.handleResize);
}

componentDidUpdate(prevProps) {
  // Data fetching based on props change
  if (prevProps.userId !== this.props.userId) {
    this.fetchUserData();
  }
}

componentWillUnmount() {
  // Cleanup event listeners
  window.removeEventListener('resize', this.handleResize);
}

Function approach (organized by concern)

// Data fetching effect
useEffect(() => {
  fetchUserData();
}, [userId]); // Re-fetch when userId changes

// Event listener effect
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []); // Only add/remove once

Gotcha: The Stale Closure Problem

One common issue when refactoring is the "stale closure" problem, where effect callbacks capture outdated values.

Class approach (always has latest this.state)

componentDidMount() {
  this.intervalId = setInterval(() => {
    // Always refers to current state
    this.setState({ count: this.state.count + 1 });
  }, 1000);
}

componentWillUnmount() {
  clearInterval(this.intervalId);
}

Function approach (potential stale closure)

// PROBLEM: count value is "frozen" in the closure
useEffect(() => {
  const intervalId = setInterval(() => {
    // count will always be the initial value!
    setCount(count + 1);
  }, 1000);
  
  return () => clearInterval(intervalId);
}, []); // Empty dependency array

// SOLUTION: Use function update form
useEffect(() => {
  const intervalId = setInterval(() => {
    // This works correctly!
    setCount(prevCount => prevCount + 1);
  }, 1000);
  
  return () => clearInterval(intervalId);
}, []); // Empty dependency still works

Step-by-Step Refactoring Process

Let's walk through a methodical approach to refactoring a class component to a function component with hooks.

Step 1: Create the function component skeleton

Start by creating a basic function component with the same props:

// FROM:
class UserProfile extends React.Component {
  // ...
}

// TO:
function UserProfile(props) {
  // We'll fill this in
  return null;
}

Step 2: Convert state with useState

Replace this.state with individual useState hooks:

// FROM:
constructor(props) {
  super(props);
  this.state = {
    user: null,
    isLoading: true,
    error: null
  };
}

// TO:
function UserProfile(props) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
  
  // ...
}

Step 3: Identify lifecycle methods and group by purpose

Analyze your lifecycle methods and group related functionality:

// Data fetching lifecycle
componentDidMount() {
  this.fetchUserData();
}

componentDidUpdate(prevProps) {
  if (prevProps.userId !== this.props.userId) {
    this.fetchUserData();
  }
}

// Activity tracking lifecycle
componentDidMount() {
  this.trackUserActivity();
}

componentWillUnmount() {
  this.stopTrackingActivity();
}

Step 4: Convert each group to a useEffect

Create separate useEffect hooks for each concern:

function UserProfile({ userId }) {
  // ...
  
  // Data fetching effect
  useEffect(() => {
    async function fetchUserData() {
      setIsLoading(true);
      try {
        const data = await fetchFromAPI(`/users/${userId}`);
        setUser(data);
        setError(null);
      } catch (err) {
        setError(err.message);
      } finally {
        setIsLoading(false);
      }
    }
    
    fetchUserData();
  }, [userId]); // Re-run when userId changes
  
  // Activity tracking effect
  useEffect(() => {
    const tracker = trackUserActivity();
    
    // Cleanup function
    return () => {
      stopTrackingActivity(tracker);
    };
  }, []); // Run once on mount
  
  // ...
}

Step 5: Convert the render method

Convert the render method to the function's return value:

// FROM:
render() {
  const { user, isLoading, error } = this.state;
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;
  
  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// TO:
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
  
  // ... effects here ...
  
  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!user) return <div>No user found</div>;
  
  return (
    <div className="user-profile">
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

Step 6: Convert class methods and event handlers

Convert class methods to regular functions inside the component:

// FROM:
handleClick = () => {
  this.setState({ count: this.state.count + 1 });
}

// TO:
function Counter() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    setCount(prevCount => prevCount + 1);
  };
  
  return (
    <button onClick={handleClick}>
      Count: {count}
    </button>
  );
}

Practical Exercise: Refactoring a Weather Widget

Let's apply what we've learned to refactor a weather widget component that fetches weather data, updates periodically, and cleans up resources.

Step 1: Understand the Original Class Component

Create a folder called src/components/weather and inside it, create a file named WeatherWidgetClass.js with the following content:

// src/components/weather/WeatherWidgetClass.js
import React from 'react';

class WeatherWidgetClass extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      temperature: null,
      conditions: '',
      location: this.props.location || 'New York',
      loading: true,
      error: null,
      lastUpdated: null
    };
  }

  componentDidMount() {
    // Initial weather fetch
    this.fetchWeatherData();
    
    // Set up a refresh interval (every 10 minutes)
    this.refreshInterval = setInterval(() => {
      this.fetchWeatherData();
    }, 10 * 60 * 1000);
    
    // Listen for online/offline events
    window.addEventListener('online', this.handleOnline);
    window.addEventListener('offline', this.handleOffline);
  }
  
  componentDidUpdate(prevProps) {
    // Fetch new data when location changes
    if (prevProps.location !== this.props.location) {
      this.setState({ 
        location: this.props.location,
        loading: true 
      }, () => {
        this.fetchWeatherData();
      });
    }
  }
  
  componentWillUnmount() {
    // Clean up the interval
    clearInterval(this.refreshInterval);
    
    // Remove event listeners
    window.removeEventListener('online', this.handleOnline);
    window.removeEventListener('offline', this.handleOffline);
  }
  
  fetchWeatherData = async () => {
    try {
      this.setState({ loading: true });
      
      // Simulating an API call
      const response = await fetch(
        `https://api.example.com/weather?location=${this.state.location}`
      );
      
      // For this example, we'll mock the response
      // In a real app, you would await response.json()
      const mockData = {
        temperature: Math.floor(Math.random() * 30) + 10,
        conditions: ['Sunny', 'Cloudy', 'Rainy', 'Snowy'][Math.floor(Math.random() * 4)]
      };
      
      this.setState({
        temperature: mockData.temperature,
        conditions: mockData.conditions,
        loading: false,
        error: null,
        lastUpdated: new Date()
      });
    } catch (error) {
      this.setState({
        loading: false,
        error: 'Failed to fetch weather data'
      });
      console.error('Weather fetch error:', error);
    }
  }
  
  handleOnline = () => {
    console.log('Online again! Refreshing weather data...');
    this.fetchWeatherData();
  }
  
  handleOffline = () => {
    console.log('Offline. Weather updates paused.');
  }
  
  render() {
    const { temperature, conditions, location, loading, error, lastUpdated } = this.state;
    
    if (loading && !temperature) {
      return <div className="weather-widget loading">Loading weather data...</div>;
    }
    
    if (error) {
      return (
        <div className="weather-widget error">
          <p>{error}</p>
          <button onClick={this.fetchWeatherData}>Retry</button>
        </div>
      );
    }
    
    return (
      <div className="weather-widget">
        <h3>Weather for {location}</h3>
        <div className="temperature">{temperature}°C</div>
        <div className="conditions">{conditions}</div>
        {lastUpdated && (
          <div className="last-updated">
            Updated: {lastUpdated.toLocaleTimeString()}
          </div>
        )}
        {loading && <div className="refreshing">Refreshing...</div>}
        <button 
          onClick={this.fetchWeatherData}
          disabled={loading}
        >
          Refresh
        </button>
      </div>
    );
  }
}

export default WeatherWidgetClass;

Step 2: Create the Function Component

Now, create a new file in the same folder named WeatherWidgetFunction.js where we'll implement the refactored component:

// src/components/weather/WeatherWidgetFunction.js
import React, { useState, useEffect } from 'react';

function WeatherWidgetFunction({ location = 'New York' }) {
  // State variables (replacing this.state)
  const [temperature, setTemperature] = useState(null);
  const [conditions, setConditions] = useState('');
  const [currentLocation, setCurrentLocation] = useState(location);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [lastUpdated, setLastUpdated] = useState(null);
  
  // This effect handles data fetching and replaces both
  // componentDidMount and componentDidUpdate(for location changes)
  useEffect(() => {
    // Update the location state when prop changes
    setCurrentLocation(location);
    setLoading(true);
    
    // Fetch weather data
    fetchWeatherData();
    
    // Set up the refresh interval
    const refreshInterval = setInterval(() => {
      fetchWeatherData();
    }, 10 * 60 * 1000);
    
    // Cleanup function (componentWillUnmount)
    return () => {
      clearInterval(refreshInterval);
    };
  }, [location]); // Re-run when location changes
  
  // Separate effect for online/offline events
  useEffect(() => {
    // Event handler functions
    const handleOnline = () => {
      console.log('Online again! Refreshing weather data...');
      fetchWeatherData();
    };
    
    const handleOffline = () => {
      console.log('Offline. Weather updates paused.');
    };
    
    // Add event listeners (componentDidMount)
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    
    // Cleanup function (componentWillUnmount)
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []); // Empty array means "run once"
  
  // The fetch function (converted from class method)
  const fetchWeatherData = async () => {
    try {
      setLoading(true);
      
      // Simulating an API call
      const response = await fetch(
        `https://api.example.com/weather?location=${currentLocation}`
      );
      
      // For this example, we'll mock the response
      const mockData = {
        temperature: Math.floor(Math.random() * 30) + 10,
        conditions: ['Sunny', 'Cloudy', 'Rainy', 'Snowy'][Math.floor(Math.random() * 4)]
      };
      
      setTemperature(mockData.temperature);
      setConditions(mockData.conditions);
      setLoading(false);
      setError(null);
      setLastUpdated(new Date());
    } catch (error) {
      setLoading(false);
      setError('Failed to fetch weather data');
      console.error('Weather fetch error:', error);
    }
  };
  
  // Render JSX (converted from render method)
  if (loading && !temperature) {
    return <div className="weather-widget loading">Loading weather data...</div>;
  }
  
  if (error) {
    return (
      <div className="weather-widget error">
        <p>{error}</p>
        <button onClick={fetchWeatherData}>Retry</button>
      </div>
    );
  }
  
  return (
    <div className="weather-widget">
      <h3>Weather for {currentLocation}</h3>
      <div className="temperature">{temperature}°C</div>
      <div className="conditions">{conditions}</div>
      {lastUpdated && (
        <div className="last-updated">
          Updated: {lastUpdated.toLocaleTimeString()}
        </div>
      )}
      {loading && <div className="refreshing">Refreshing...</div>}
      <button 
        onClick={fetchWeatherData}
        disabled={loading}
      >
        Refresh
      </button>
    </div>
  );
}

export default WeatherWidgetFunction;

Step 3: Compare and Analyze the Differences

Let's analyze the key refactoring changes we made:

Class Component Approach Function Component Approach
Single state object with multiple properties Multiple useState hooks for individual state values
componentDidMount, componentDidUpdate, and componentWillUnmount lifecycle methods useEffect hooks with appropriate dependency arrays
Class methods with this binding Regular function declarations within component scope
this.setState() often requires callback for sequential updates Individual setter functions from useState can be called directly
Lifecycle logic split across different methods Related logic grouped together in the same useEffect

Advanced useEffect Patterns

Skipping the First Run

Sometimes you want an effect to behave like pure componentDidUpdate (skip the first render). Here's how:

function SkipFirstRender() {
  const [count, setCount] = useState(0);
  const isFirstRender = useRef(true);
  
  useEffect(() => {
    // Skip the first render
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }
    
    // This will only run on updates, not after the first render
    console.log(`Count changed to: ${count}`);
  }, [count]);
  
  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

Debouncing Effects

When you need to avoid running effects too frequently, like for an auto-save feature:

function AutosaveEditor() {
  const [text, setText] = useState('');
  const [isSaving, setIsSaving] = useState(false);
  const [savedText, setSavedText] = useState('');
  
  // Debounced save effect
  useEffect(() => {
    if (!text) return; // Don't save empty text
    
    setIsSaving(true);
    
    // Wait 1 second after typing stops before saving
    const timeoutId = setTimeout(() => {
      console.log('Saving:', text);
      // Simulate API save
      setSavedText(text);
      setIsSaving(false);
    }, 1000);
    
    // Cancel timeout if text changes again within 1 second
    return () => clearTimeout(timeoutId);
  }, [text]);
  
  return (
    <div>
      <textarea
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Start typing to auto-save..."
      />
      <div>
        {isSaving ? 'Saving...' : 'Saved!'}
      </div>
      <div>
        <strong>Saved content:</strong>
        <p>{savedText || 'Nothing saved yet'}</p>
      </div>
    </div>
  );
}

Coordinating Multiple Effects

Sometimes you need to coordinate between multiple effects, such as for a login system:

function UserSession() {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [authError, setAuthError] = useState(null);
  
  // Effect 1: Check authentication status on mount
  useEffect(() => {
    async function checkAuth() {
      try {
        setIsLoading(true);
        // Simulated auth check
        const response = await fetch('/api/auth/status');
        const data = await response.json();
        
        if (data.authenticated) {
          setUser(data.user);
        } else {
          setUser(null);
        }
        setAuthError(null);
      } catch (error) {
        setAuthError('Failed to check authentication status');
      } finally {
        setIsLoading(false);
      }
    }
    
    checkAuth();
  }, []);
  
  // Effect 2: Set up activity tracking when user is logged in
  useEffect(() => {
    if (!user) return; // Only track logged in users
    
    console.log('Starting activity tracking for', user.username);
    const activityTracker = startActivityTracking(user.id);
    
    return () => {
      console.log('Stopping activity tracking');
      stopActivityTracking(activityTracker);
    };
  }, [user]); // Re-run when user changes
  
  // Effect 3: Handle authentication errors
  useEffect(() => {
    if (!authError) return;
    
    // Log error to monitoring service
    logErrorToService(authError);
    
    // Show error notification to user
    const timeoutId = setTimeout(() => {
      setAuthError(null);
    }, 5000);
    
    return () => clearTimeout(timeoutId);
  }, [authError]);
  
  // Component rendering logic...
}

Practice Exercise: Convert a Todo App

To solidify your understanding, try this exercise on your own. Start with this class-based Todo component and convert it to a function component using hooks.

The Class Component

Create a file named src/components/todos/TodoClassComponent.js:

import React from 'react';

class TodoApp extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      todos: [],
      newTodoText: '',
      isLoading: true,
      error: null
    };
  }

  componentDidMount() {
    // Load todos from localStorage
    this.loadTodos();
    
    // Set up beforeunload event to save todos
    window.addEventListener('beforeunload', this.saveTodos);
  }
  
  componentDidUpdate(prevProps, prevState) {
    // Save todos when they change
    if (prevState.todos !== this.state.todos) {
      localStorage.setItem('todos', JSON.stringify(this.state.todos));
    }
  }
  
  componentWillUnmount() {
    window.removeEventListener('beforeunload', this.saveTodos);
  }
  
  loadTodos = () => {
    try {
      const savedTodos = localStorage.getItem('todos');
      if (savedTodos) {
        this.setState({ 
          todos: JSON.parse(savedTodos),
          isLoading: false
        });
      } else {
        this.setState({ isLoading: false });
      }
    } catch (error) {
      this.setState({ 
        error: 'Failed to load todos',
        isLoading: false
      });
    }
  }
  
  saveTodos = () => {
    localStorage.setItem('todos', JSON.stringify(this.state.todos));
  }
  
  handleInputChange = (e) => {
    this.setState({ newTodoText: e.target.value });
  }
  
  handleAddTodo = (e) => {
    e.preventDefault();
    
    if (!this.state.newTodoText.trim()) return;
    
    const newTodo = {
      id: Date.now(),
      text: this.state.newTodoText,
      completed: false
    };
    
    this.setState(prevState => ({
      todos: [...prevState.todos, newTodo],
      newTodoText: ''
    }));
  }
  
  handleToggleTodo = (todoId) => {
    this.setState(prevState => ({
      todos: prevState.todos.map(todo => 
        todo.id === todoId 
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    }));
  }
  
  handleDeleteTodo = (todoId) => {
    this.setState(prevState => ({
      todos: prevState.todos.filter(todo => todo.id !== todoId)
    }));
  }
  
  render() {
    const { todos, newTodoText, isLoading, error } = this.state;
    
    if (isLoading) {
      return <div>Loading todos...</div>;
    }
    
    if (error) {
      return <div>Error: {error}</div>;
    }
    
    return (
      <div className="todo-app">
        <h2>Todo List</h2>
        
        <form onSubmit={this.handleAddTodo}>
          <input
            type="text"
            value={newTodoText}
            onChange={this.handleInputChange}
            placeholder="Add a new todo..."
          />
          <button type="submit">Add</button>
        </form>
        
        <ul className="todo-list">
          {todos.length === 0 ? (
            <li className="empty-state">No todos yet. Add one above!</li>
          ) : (
            todos.map(todo => (
              <li 
                key={todo.id}
                className={todo.completed ? 'completed' : ''}
              >
                <input
                  type="checkbox"
                  checked={todo.completed}
                  onChange={() => this.handleToggleTodo(todo.id)}
                />
                <span>{todo.text}</span>
                <button 
                  onClick={() => this.handleDeleteTodo(todo.id)}
                  className="delete-btn"
                >
                  Delete
                </button>
              </li>
            ))
          )}
        </ul>
      </div>
    );
  }
}

export default TodoApp;

Your Task: Convert to Function Component

Create a new file named src/components/todos/TodoFunctionComponent.js and implement the same functionality using hooks. Here's a template to get you started:

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

function TodoApp() {
  // TODO: Implement state with useState
  
  // TODO: Implement lifecycle logic with useEffect
  
  // TODO: Implement event handlers
  
  // TODO: Implement render logic
}

export default TodoApp;

Solution

Once you've attempted the conversion yourself, you can check a solution here:

Click to reveal solution
import React, { useState, useEffect } from 'react';

function TodoApp() {
  // State
  const [todos, setTodos] = useState([]);
  const [newTodoText, setNewTodoText] = useState('');
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
  
  // Effect for loading todos (componentDidMount)
  useEffect(() => {
    loadTodos();
    
    // Set up beforeunload event (componentDidMount)
    window.addEventListener('beforeunload', saveTodos);
    
    // Cleanup (componentWillUnmount)
    return () => {
      window.removeEventListener('beforeunload', saveTodos);
    };
  }, []); // Empty dependency array = run once
  
  // Effect for saving todos (componentDidUpdate)
  useEffect(() => {
    // Skip the initial render
    if (isLoading) return;
    
    // Save todos when they change
    localStorage.setItem('todos', JSON.stringify(todos));
  }, [todos, isLoading]); // Re-run when todos change
  
  const loadTodos = () => {
    try {
      const savedTodos = localStorage.getItem('todos');
      if (savedTodos) {
        setTodos(JSON.parse(savedTodos));
      }
    } catch (error) {
      setError('Failed to load todos');
    } finally {
      setIsLoading(false);
    }
  };
  
  const saveTodos = () => {
    localStorage.setItem('todos', JSON.stringify(todos));
  };
  
  const handleInputChange = (e) => {
    setNewTodoText(e.target.value);
  };
  
  const handleAddTodo = (e) => {
    e.preventDefault();
    
    if (!newTodoText.trim()) return;
    
    const newTodo = {
      id: Date.now(),
      text: newTodoText,
      completed: false
    };
    
    setTodos(prevTodos => [...prevTodos, newTodo]);
    setNewTodoText('');
  };
  
  const handleToggleTodo = (todoId) => {
    setTodos(prevTodos => 
      prevTodos.map(todo => 
        todo.id === todoId 
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    );
  };
  
  const handleDeleteTodo = (todoId) => {
    setTodos(prevTodos => 
      prevTodos.filter(todo => todo.id !== todoId)
    );
  };
  
  if (isLoading) {
    return <div>Loading todos...</div>;
  }
  
  if (error) {
    return <div>Error: {error}</div>;
  }
  
  return (
    <div className="todo-app">
      <h2>Todo List</h2>
      
      <form onSubmit={handleAddTodo}>
        <input
          type="text"
          value={newTodoText}
          onChange={handleInputChange}
          placeholder="Add a new todo..."
        />
        <button type="submit">Add</button>
      </form>
      
      <ul className="todo-list">
        {todos.length === 0 ? (
          <li className="empty-state">No todos yet. Add one above!</li>
        ) : (
          todos.map(todo => (
            <li 
              key={todo.id}
              className={todo.completed ? 'completed' : ''}
            >
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => handleToggleTodo(todo.id)}
              />
              <span>{todo.text}</span>
              <button 
                onClick={() => handleDeleteTodo(todo.id)}
                className="delete-btn"
              >
                Delete
              </button>
            </li>
          ))
        )}
      </ul>
    </div>
  );
}

Common Mistakes and How to Avoid Them

Mistake 1: Missing Dependencies

One of the most common mistakes is forgetting to include all dependencies in the dependency array:

// ❌ INCORRECT: userId is used in fetchData but not included in deps
useEffect(() => {
  async function fetchData() {
    const data = await fetchUserPosts(userId);
    setPosts(data);
  }
  fetchData();
}, []); // Missing userId dependency

// ✅ CORRECT: All dependencies are included
useEffect(() => {
  async function fetchData() {
    const data = await fetchUserPosts(userId);
    setPosts(data);
  }
  fetchData();
}, [userId]); // userId is included

Use the ESLint plugin "eslint-plugin-react-hooks" which will warn you about missing dependencies.

Mistake 2: Infinite Loops

Updating state inside an effect without proper dependencies can cause infinite render loops:

// ❌ INCORRECT: No dependency array, creates infinite loop
useEffect(() => {
  // This runs after every render
  setCount(count + 1); // Triggers another render
}); // No dependency array

// ✅ CORRECT: Only run when specific values change
useEffect(() => {
  if (isAutoIncrement) {
    setCount(count + 1);
  }
}, [isAutoIncrement]); // Only run when isAutoIncrement changes

Mistake 3: Object/Array Dependencies

Using objects or arrays directly in dependency arrays can cause unnecessary effect runs:

// ❌ INCORRECT: New object created every render
const options = { threshold: 0.5 };

useEffect(() => {
  const observer = new IntersectionObserver(callback, options);
  // ...
}, [options]); // options is a new object each render

// ✅ CORRECT: Use primitive values as dependencies
const threshold = 0.5;

useEffect(() => {
  const options = { threshold };
  const observer = new IntersectionObserver(callback, options);
  // ...
}, [threshold]); // threshold is a stable primitive value

Mistake 4: Not Cleaning Up Subscriptions

Forgetting to clean up subscriptions can lead to memory leaks:

// ❌ INCORRECT: No cleanup function
useEffect(() => {
  const subscription = dataSource.subscribe();
  // No cleanup!
}, []);

// ✅ CORRECT: With proper cleanup
useEffect(() => {
  const subscription = dataSource.subscribe();
  
  // Return a cleanup function
  return () => {
    subscription.unsubscribe();
  };
}, []);

Performance Optimization with useCallback and useMemo

When refactoring complex class components, you may need to optimize performance using additional hooks:

useCallback for Stable Functions

Use useCallback to prevent function recreations between renders:

// Without useCallback, handleClick is recreated on every render
function Counter() {
  const [count, setCount] = useState(0);
  
  // This function is recreated on every render
  const handleClick = () => {
    setCount(count + 1);
  };
  
  return <button onClick={handleClick}>Count: {count}</button>;
}

// With useCallback, handleClick is stable between renders
function OptimizedCounter() {
  const [count, setCount] = useState(0);
  
  // This function is only recreated when count changes
  const handleClick = useCallback(() => {
    setCount(count + 1);
  }, [count]);
  
  return <button onClick={handleClick}>Count: {count}</button>;
}

useMemo for Expensive Calculations

Use useMemo to cache expensive calculations:

// Without useMemo, filteredItems is recalculated on every render
function ProductList({ products, searchTerm }) {
  // This is recalculated on every render
  const filteredProducts = products.filter(product => 
    product.name.toLowerCase().includes(searchTerm.toLowerCase())
  );
  
  return (
    <ul>
      {filteredProducts.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

// With useMemo, filteredItems is only recalculated when dependencies change
function OptimizedProductList({ products, searchTerm }) {
  // This is only recalculated when products or searchTerm change
  const filteredProducts = useMemo(() => {
    console.log('Filtering products...');
    return products.filter(product => 
      product.name.toLowerCase().includes(searchTerm.toLowerCase())
    );
  }, [products, searchTerm]);
  
  return (
    <ul>
      {filteredProducts.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Real-World Refactoring Tips

Tip 1: Refactor Incrementally

Don't try to refactor an entire complex component at once. Start with a small part:

  1. Convert simple state variables first
  2. Convert one lifecycle method at a time
  3. Keep both versions working side by side during the transition
  4. Add comprehensive tests before refactoring

Tip 2: Use Custom Hooks for Reusable Logic

Extract common patterns into custom hooks for reuse:

// Extract localStorage logic into a custom hook
function useLocalStorage(key, initialValue) {
  // State to store our value
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });
  
  // Return a wrapped version of useState's setter function
  const setValue = value => {
    try {
      // Allow value to be a function
      const valueToStore = 
        value instanceof Function ? value(storedValue) : value;
      // Save state
      setStoredValue(valueToStore);
      // Save to localStorage
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };
  
  return [storedValue, setValue];
}

// Now our TodoApp can be simplified
function TodoApp() {
  const [todos, setTodos] = useLocalStorage('todos', []);
  const [newTodoText, setNewTodoText] = useState('');
  
  // Rest of component...
}

Tip 3: Document Your Refactoring Decisions

Add comments that explain the mapping between old and new code:

// Function component version of MyComponent
function MyComponent(props) {
  // --- STATE MIGRATION ---
  // this.state.count -> useState
  const [count, setCount] = useState(0);
  
  // --- LIFECYCLE MIGRATION ---
  // componentDidMount + componentWillUnmount -> useEffect with empty deps
  useEffect(() => {
    console.log('Mounted');
    
    return () => {
      console.log('Unmounting');
    };
  }, []);
  
  // componentDidUpdate for count changes -> useEffect with count dependency
  useEffect(() => {
    document.title = `Count: ${count}`;
  }, [count]);
  
  // --- EVENT HANDLER MIGRATION ---
  // this.handleClick -> regular function
  const handleClick = () => {
    setCount(count + 1);
  };
  
  // --- RENDER MIGRATION ---
  return (
    <div>
      <p>{count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}

Summary and Key Takeaways

Refactoring class components to function components with hooks brings many benefits:

Lifecycle Method to useEffect Mapping

Class Lifecycle Method Function Component Equivalent
constructor useState
componentDidMount useEffect(() => {}, [])
componentDidUpdate useEffect(() => {}, [dependency])
componentWillUnmount useEffect(() => { return () => {} }, [])
getDerivedStateFromProps useEffect with prop dependencies
shouldComponentUpdate React.memo + useCallback/useMemo