Published on

Avoiding Race Conditions in useEffect! 🎢

Authors
  • avatar
    Name
    Mamun Rashid

Ever had a guest at a dinner party who just couldn’t make up their mind about what to order? 🍝 First, they ask for pasta, then change their mind and want pizza. So, we’re cooking both meals at once. But, uh-oh! if the pasta finishes first, we might accidentally serve it, even though the guest really wanted pizza!

That’s exactly the chaos we can encounter when using useEffect in React if we’re not careful. Misusing useEffect can lead to race conditions, where earlier actions (like data fetching) finish after newer ones and we end up with outdated or wrong information in our app.

In this post, we’ll explore how this common problem occurs, look at a few scenarios to avoid, and learn how React’s clean-up function can help us prevent these race conditions and keep our apps running smoothly. Let's dive in!

The Single, Indecisive Guest: How Race Conditions Happen in React 🎯

Let’s break it down with a fun example.

We have one guest at our dinner party. First, they ask for pasta. We start cooking. Then, halfway through, they change their mind and ask for pizza instead. 🍕 Now, we’re cooking two meals at the same time. If the pasta finishes first, we mistakenly serve it to the guest and even though they really wanted the pizza!

This scenario plays out in React when we update state and trigger multiple asynchronous tasks (like API requests) using useEffect. If we don’t handle it properly, earlier requests might finish after newer ones, leaving our app showing old or incorrect information.

The Messy Kitchen: Race Conditions in Code 🍝

Here's how it looks in code:

import { useEffect, useState } from 'react';

function MealOrderComponent({ currentOrder }) {
    const [meal, setMeal] = useState(null);
    const [isCooking, setIsCooking] = useState(false);

    useEffect(() => {
        const prepareMeal = async (mealId) => {
            setIsCooking(true);
            await sleep(Math.random() * 3000); // Meal prep takes a random time
            setMeal(mealId); // Serve meal to guest
            setIsCooking(false);
        };
        prepareMeal(currentOrder);
    }, [currentOrder]);

    return <div>{isCooking ? 'Cooking...' : `Served: ${meal}`}</div>;
}

In this example:

  • currentOrder represents what meal the guest wants (the pasta or the pizza).
  • prepareMeal simulates cooking the meal, but it takes a random amount of time to finish.
  • setMeal(mealId) represents serving the meal to the guest.

If we don’t manage this carefully, we might serve the pasta (the earlier request) even if the guest changed their mind to pizza (the latest request).

This is a classic race condition! 🍝➡️❌🍕 And it happens because both requests are racing to finish, and the first to complete wins, regardless of whether it’s the right one.

Chaos in the Kitchen: How Do We Fix It? 🧑‍🍳

To fix this, we need a way to cancel the pasta order if the guest changes their mind. We can do this by adding a "sticky note" to the kitchen. Whenever the guest updates their order, we leave a note saying, "Cancel the pasta if it finishes after the pizza starts cooking!"

The Hero: Clean-Up Function to Save the Day 🎉

React provides a way to clean up or cancel tasks within useEffect using clean-up functions. Here’s how we implement this in our code to make sure we always serve the most recent meal:

useEffect(() => {
    let cancelPreviousMeal = false; // The sticky note: "Cancel the earlier meal"

    const prepareMeal = async (mealId) => {
        setIsCooking(true);
        await sleep(Math.random() * 3000); // Random meal prep time

        if (!cancelPreviousMeal) {
            // Only serve if no cancellation happened
            setMeal(mealId); // Serve the correct meal
        }

        setIsCooking(false); // Always reset cooking state
    };

    prepareMeal(currentOrder);

    return () => {
        cancelPreviousMeal = true; // Cancel the earlier meal when the new one starts
    };
}, [currentOrder]);

Important Note: We moved setIsCooking(false) outside the condition so it always runs, preventing the UI from getting stuck in a "cooking" state even when the result is ignored.

Why This Works: The Sticky Note System 📝🍽️

Here’s how it saves the day:

  • cancelPreviousMeal acts as our sticky note, allowing us to track if an old meal should be ignored.
  • Every time the guest changes their order (when currentOrder updates), we apply the sticky note, canceling the earlier meal if it finishes after the new one starts.
  • With the clean-up function in place, we ensure that we only serve the correct, most recent meal (pizza, in this case!). 🍕🎉

Real-World Example: Data Fetching 🌐

The most common place race conditions occur is when fetching data. Let's say we're building a user profile viewer that loads different user data as the user navigates:

import { useEffect, useState } from 'react';

function UserProfile({ userId }) {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        let ignore = false; // Flag to ignore stale requests

        const fetchUser = async () => {
            setLoading(true);
            setError(null);

            try {
                const response = await fetch(`/api/users/${userId}`);
                const data = await response.json();

                if (!ignore) {
                    // Only update if this is still the current request
                    setUser(data);
                }
            } catch (err) {
                if (!ignore) {
                    setError(err.message);
                }
            } finally {
                if (!ignore) {
                    setLoading(false);
                }
            }
        };

        fetchUser();

        return () => {
            ignore = true; // Mark this request as stale when userId changes
        };
    }, [userId]);

    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error}</div>;
    return <div>User: {user?.name}</div>;
}

What happens here:

  • If userId changes from 123 quickly, three fetch requests start
  • Request for user 1 might finish last (due to network delays)
  • Without the ignore flag, we'd show user 1's data even though we want user 3
  • The cleanup function sets ignore = true, preventing stale data from being displayed

Modern Approach: Using AbortController 🛑

For fetch requests, the modern and recommended approach is using AbortController, which actually cancels the network request instead of just ignoring the result:

useEffect(() => {
    const controller = new AbortController();
    const signal = controller.signal;

    const fetchUser = async () => {
        setLoading(true);
        setError(null);

        try {
            const response = await fetch(`/api/users/${userId}`, { signal });
            const data = await response.json();
            setUser(data);
        } catch (err) {
            // Ignore abort errors
            if (err.name !== 'AbortError') {
                setError(err.message);
            }
        } finally {
            setLoading(false);
        }
    };

    fetchUser();

    return () => {
        controller.abort(); // Actually cancel the fetch request
    };
}, [userId]);

Benefits of AbortController:

  • ✅ Actually cancels the network request (saves bandwidth)
  • ✅ Browser-native API (no extra dependencies)
  • ✅ Cleaner syntax with signal passing
  • ✅ Automatically throws AbortError when cancelled
  • ✅ Supported in all modern browsers (Chrome 66+, Firefox 57+, Safari 12.1+)

TypeScript Version

For better type safety, here's the TypeScript version:

import { useEffect, useState } from 'react';

interface User {
    id: number;
    name: string;
    email: string;
}

function UserProfile({ userId }: { userId: number }) {
    const [user, setUser] = useState<User | null>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        const controller = new AbortController();

        const fetchUser = async () => {
            setLoading(true);
            setError(null);

            try {
                const response = await fetch(`/api/users/${userId}`, {
                    signal: controller.signal,
                });

                if (!response.ok) {
                    throw new Error(`HTTP error! status: ${response.status}`);
                }

                const data: User = await response.json();
                setUser(data);
            } catch (err) {
                if (err instanceof Error && err.name !== 'AbortError') {
                    setError(err.message);
                }
            } finally {
                setLoading(false);
            }
        };

        fetchUser();

        return () => {
            controller.abort();
        };
    }, [userId]);

    if (loading) return <div>Loading...</div>;
    if (error) return <div>Error: {error}</div>;
    return <div>User: {user?.name}</div>;
}

React 18+ Strict Mode Considerations ⚛️

In React 18+, Strict Mode will intentionally double-invoke effects in development to help you find bugs. This means:

  • Intended effect runs once
  • Cleanup function runs
  • Effect runs again

This is intentional and helps catch missing cleanup logic. If you see double requests in development, that's normal! In production, effects only run once.

Best Practices Summary 📋

  1. Always use cleanup functions for async operations in useEffect
  2. Prefer AbortController for fetch requests (modern & efficient)
  3. Use boolean flags (ignore or cancelled) for other async tasks
  4. Handle errors properly - ignore AbortError from cancelled requests
  5. Test in Strict Mode - it helps catch missing cleanup logic
  6. Consider custom hooks - abstract this pattern for reusability

Wrapping It Up: Keep the Kitchen and Code Clean! 🧹

When working with asynchronous tasks in React, such as fetching data or handling user actions, race conditions can easily occur if we don't manage requests properly. By using clean-up functions in useEffect, we can prevent old tasks from interfering with new ones, ensuring that only the most recent actions take effect.

Modern tools like AbortController make this even easier by actually canceling network requests instead of just ignoring their results. Combined with React 18's Strict Mode helping us catch bugs early, we have everything we need to write robust async code.

Just like a well-organized kitchen where we cancel old orders and serve the right meal, our apps will run smoothly and avoid serving up outdated data! 🍕

Enjoyed this post?

Subscribe to get notified about new posts and updates. No spam, unsubscribe anytime.

By subscribing, you agree to our Privacy Policy. You can unsubscribe at any time.

Discussion (0)

This website is still under development. If you encounter any issues, please contact me