Mastering React Hooks
Mapping Class Lifecycles
From Lifecycles to Synchronisation
Moving from class components to functional components in React involves a crucial mental shift. Instead of thinking about distinct lifecycle phases like mounting, updating, and unmounting, we start thinking about synchronising our component's state with the world outside of React. This 'outside world' could be a network request, the browser's DOM, or a timer. The main tool for this job is the useEffect hook.
The core idea is this: your effects synchronise your component with an external system, and they keep doing so over time.
In class components, logic for a single feature was often split across multiple lifecycle methods. For example, you might fetch data in componentDidMount and set up a subscription, then tear down that subscription in componentWillUnmount. Hooks allow you to group related logic together.
Mounting and Updating
Let's map the old lifecycle methods to their useEffect equivalents. The key is the —the second argument to useEffect. This array tells React when to re-run your effect.
To mimic componentDidMount, you provide an empty dependency array []. This tells React the effect doesn't depend on any props or state, so it only needs to run once after the initial render.
// Class component
componentDidMount() {
document.title = 'Component Mounted';
}
// Functional equivalent
import { useEffect } from 'react';
useEffect(() => {
document.title = 'Component Mounted';
}, []); // Empty array means run once
To mimic componentDidUpdate, you can omit the dependency array entirely. This causes the effect to run after every single render. This is often inefficient and can cause infinite loops if you're not careful.
// Runs after every render
useEffect(() => {
console.log('Component re-rendered');
});
A much more common and useful pattern is to provide specific values in the dependency array. The effect will then only re-run if one of those values changes between renders. This is perfect for reacting to changes in specific props or state.
// Class component
componentDidUpdate(prevProps) {
if (prevProps.userId !== this.props.userId) {
this.fetchData(this.props.userId);
}
}
// Functional equivalent
useEffect(() => {
fetchData(props.userId);
}, [props.userId]); // Re-runs only if userId changes
Cleaning Up Effects
What about componentWillUnmount? This is where you would clean up timers, event listeners, or network subscriptions to prevent in your application.
In useEffect, you achieve this by returning a function from your effect. React will execute this cleanup function when the component unmounts.
// Class component
class ChatAPI extends React.Component {
componentDidMount() {
API.subscribeToFriendStatus(this.props.friend.id, this.handleStatusChange);
}
componentWillUnmount() {
API.unsubscribeFromFriendStatus(this.props.friend.id, this.handleStatusChange);
}
handleStatusChange = (status) => {
// ...
}
}
With useEffect, the setup and cleanup logic live together, making the code easier to read and maintain. The logic is co-located.
// Functional equivalent
function FriendStatus(props) {
useEffect(() => {
// This is the setup part (like componentDidMount)
API.subscribeToFriendStatus(props.friend.id, handleStatusChange);
// Return a function to handle cleanup (like componentWillUnmount)
return () => {
API.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
}, [props.friend.id]); // Don't forget dependencies!
// ...
}
Crucially, the cleanup function also runs before the effect runs again for a subsequent render (but not the first one). This ensures that you don't have multiple subscriptions active at the same time if a prop like props.friend.id changes.
Time for a quick check-in. Test your understanding of how useEffect maps to class lifecycles.
What is the primary mental shift when moving from class components to functional components with hooks?
To run a side effect only once after the component's initial render, similar to componentDidMount, what should you pass as the second argument to useEffect?