Skip to main content

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.

  1. Render 1: userId is 1. The component starts fetch('/users/1').
  2. Render 2: userId changes to 2. The component starts fetch('/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 to user: { id: 2, name: 'User Two' }. The UI correctly displays "User Two".
  • A moment later, fetch('/users/1') finishes. The component's state is set to user: { 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:

  1. Render 1 (userId is 1): The effect runs. It creates an AbortController and starts fetch('/users/1').
  2. Render 2 (userId is 2): The userId prop has changed, so React needs to re-run the effect.
  3. Cleanup: Before running the new effect, React runs the cleanup function from the previous effect (the one for userId 1). This calls controller.abort() on the controller for the fetch('/users/1') request.
  4. New Effect: The new effect for userId 2 runs, creating a new AbortController and starting fetch('/users/2').
  5. The fetch('/users/1') request will now fail and be caught by the catch block. Because we check if (e.name !== 'AbortError'), we do nothing, and no state is updated.
  6. 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.