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:
- Setting up a timer and event listener when the component mounts
- Responding to state changes during updates
- Cleaning up resources when the component unmounts
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:
- A function containing the effect code
- 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:
- Convert simple state variables first
- Convert one lifecycle method at a time
- Keep both versions working side by side during the transition
- 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:
- Improved code organization: Related logic stays together instead of being split across lifecycle methods
- More reusable logic: Custom hooks enable better code sharing
- Smaller bundle size: Function components typically compile to less code
- Better performance: Hooks make fine-grained updates more natural
- Fewer bugs: Less need for this binding and complex state merging
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 |