Fetching Data with `useEffect` (Part 2) #71
📖 Introduction
In Part 1, we built a robust pattern for fetching data when a component mounts. But what happens when the data we need to fetch depends on a prop or state that can change? If the user can trigger changes quickly, we can run into a classic trap: a race condition.
This article will teach you how to handle this advanced scenario, ensuring your component always displays the correct, most up-to-date data, even when network requests resolve in an unexpected order.
📚 Prerequisites
This article builds directly on the previous one. You should be an expert in:
- The complete data-fetching pattern (managing loading, error, and data states).
- The
useEffect
dependency array and cleanup function. - The
AbortController
browser API.
🎯 Article Outline: What You'll Master
By the end of this article, you will be able to build truly bulletproof data-fetching components:
- ✅ The Problem: Understanding what a "race condition" is and how it leads to stale data in your UI.
- ✅ The Solution: How to use the
useEffect
cleanup function to "cancel" previous, outdated network requests. - ✅ Practical Application: Refactoring a data-fetching component to correctly handle rapid changes in its dependencies.
🧠 Section 1: The Race Condition Explained
Imagine a component that fetches and displays a user's profile based on a userId
prop.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // The effect re-runs whenever userId changes.
// ... render UI ...
}
Now, imagine the userId
prop changes very quickly from 1
to 2
.
- Render 1:
userId
is1
. The component startsfetch('/users/1')
. - Render 2:
userId
changes to2
. The component startsfetch('/users/2')
.
Now we have two network requests happening at the same time. What if, due to random network latency, the request for user 1 is slow and the request for user 2 is fast?
fetch('/users/2')
finishes first. The component's state is set touser: { id: 2, name: 'User Two' }
. The UI correctly displays "User Two".- A moment later,
fetch('/users/1')
finishes. The component's state is set touser: { id: 1, name: 'User One' }
.
The component now displays "User One", even though the current userId
prop is 2
. This is a race condition, and it results in a UI that is displaying stale data.
💻 Section 2: The Solution - Ignoring Outdated Requests
The key to solving this is to ensure that our component only ever updates its state with the data from the most recent request it made. We can achieve this by using the useEffect
cleanup function to "ignore" the results of any previous requests.
The modern and preferred way to do this is with the AbortController
API, which we introduced in the last article. When our effect re-runs, the cleanup function from the previous effect will be called, aborting the previous network request.
🛠️ Section 3: The Complete, Race-Condition-Proof Pattern
Let's fix our UserProfile
component.
// UserProfile.jsx
import React, { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, { signal });
if (!response.ok) {
throw new Error(`HTTP Error! Status: ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (e) {
if (e.name !== 'AbortError') {
setError(e);
}
} finally {
if (!signal.aborted) {
setLoading(false);
}
}
};
fetchUser();
// This is the cleanup function.
return () => {
// When the effect re-runs (because userId changed),
// this function will be called, aborting the previous fetch request.
controller.abort();
};
}, [userId]); // The effect depends on userId.
if (loading) return <p>Loading profile...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<h1>{user?.name}</h1>
<p>Email: {user?.email}</p>
</div>
);
}
export default UserProfile;
How it Solves the Race Condition:
- Render 1 (
userId
is 1): The effect runs. It creates anAbortController
and startsfetch('/users/1')
. - Render 2 (
userId
is 2): TheuserId
prop has changed, so React needs to re-run the effect. - Cleanup: Before running the new effect, React runs the cleanup function from the previous effect (the one for
userId
1). This callscontroller.abort()
on the controller for thefetch('/users/1')
request. - New Effect: The new effect for
userId
2 runs, creating a newAbortController
and startingfetch('/users/2')
. - The
fetch('/users/1')
request will now fail and be caught by thecatch
block. Because we checkif (e.name !== 'AbortError')
, we do nothing, and no state is updated. - Only the
fetch('/users/2')
request is allowed to complete and update the state.
The stale data from the first request is never shown. The race condition is solved.
💡 Conclusion & Key Takeaways
You have now leveled up your data-fetching skills from handling simple cases to managing complex, dynamic scenarios. Understanding how to prevent race conditions is a critical step toward writing professional, production-grade React applications.
Let's review the core lesson:
- Race Conditions Happen: When an effect depends on rapidly changing data, you must account for requests resolving out of order.
- Cleanup is the Key: The
useEffect
cleanup function is the perfect tool to solve this. By aborting the previous request before starting a new one, you ensure that only the most recent, relevant data is ever used to update your component's state.
This pattern is the gold standard for fetching data that depends on props or state.
➡️ Next Steps
We have now fully explored the useEffect
hook, from its basic use to its most advanced patterns. It's time to compare this new way of thinking with the old. In the next article, "useEffect
vs. Class Lifecycle Methods", we will look at how the useEffect
hook unifies and simplifies the concepts of componentDidMount
, componentDidUpdate
, and componentWillUnmount
from React's class-based components.
Understanding the past helps us appreciate the present. Let's see how far React has come.