BEWARE of React.useEffect Race Condition 🐛 BUGS

Subscribe to my newsletter and never miss my upcoming articles

It is pretty common for React's useEffect to introduce Race Condition Bugs. This can happen any time you have asynchronous code inside of React.useEffect.

What is a Race Condition Bug?

A race condition can happen when there are two asynchronous processes that will both be updating the same value. In this scenario, it's the last process to complete that ends up updating the value.

This may not be what we want. We might want the last process to be started to update the value.

An example of this is a component that fetches data and then re-renders and re-fetches data.

Example Race Condition Component

This is an example of a component that could have a Race Condition Bug.

import { useEffect, useState } from "react";
import { getPerson } from "./api";

export const Race = ({ id }) => {
    const [person, setPerson] = useState(null);

    useEffect(() => {
        setPerson(null);

        getPerson(id).then((person) => {
            setPerson(person);
        };
    }, [id]);

    return person ? `${id} = ${person.name}` : null;
}

At first glance, there doesn't seem to be anything wrong with this code and that's what can make this bug so dangerous.

useEffect will fire every time id changes and call getPerson. If getPerson is started and the id changes, a second call to getPerson will start.

If the first call finishes before the second call, then it will overwrite person with data from the first call, causing a bug in our application.

AbortController

When using fetch, you could use an AbortController to manually abort the first request.

NOTE: Later on, we'll find a simpler way to do this. This code is just for education purposes.

import { useEffect, useRef, useState } from "react";
import { getPerson } from "./api";

export const Race = ({ id }) => {
    const [data, setData] = useState(null);
    const abortRef = useRef(null);

    useEffect(() => {
        setData(null);

        if (abortRef.current != null) {
            abortRef.current.abort();
        }

        abortRef.current = new AbortController();

        fetch(`/api/${id}`, { signal: abortRef.current.signal })
            .then((response) => {
                abortRef.current = null;
                return response;
            })
            .then((response) => response.json())
            .then(setData);
    }, [id]);

    return data;
}

Canceling the Previous Request

The AbortController isn't always an option for us since some asynchronous code doesn't work with an AbortController. So we still need a way to cancel the previous async call.

This is possible by setting a cancelled flag inside of useEffect. We can set this to true when the id changes using the unmount feature of useEffect.

NOTE: Later on, we'll find a simpler way to do this. This code is just for education purposes.

import { useEffect, useState } from "react";
import { getPerson } from "./api";

export const Race = ({ id }) => {
    const [person, setPerson] = useState(null);

    useEffect(() => {
        let cancelled = false;
        setPerson(null);

        getPerson(id).then((person) => {
            if (cancelled) return; // only proceed if NOT cancelled
            setPerson(person);
        };

        return () => {
            cancelled = true; // cancel if `id` changes
        };
    }, [id]);

    return person ? `${id} = ${person.name}` : null;
}

Use React Query

I would not recommend handling the aborting or cancelling manually inside of each component. Instead, you should wrap that functionality inside a React Hook. Fortunately there is a library that has already done that for us.

I would recommend using the react-query library. This library will prevent race condition bugs as well as provide some other nice things like caching, retries, etc.

I also like the way react-query simplifies the code.

import { useQuery } from "react-query";
import { getPerson } from "./api";

export const Race = ({ id }) => {
    const { isLoading, error, data } = useQuery(
        ["person", id],
        (key, id) => getPerson(id)
    );

    if (isLoading) return "Loading...";
    if (error) return `ERROR: ${error.toString()}`;
    return `${id} = ${data.name}`;
}

The first argument to react-query is the cache key and the 2nd is a function that will be called when there is no cache or the cache is stale or invalid.

Summary

Race condition bugs can occur when there is an asynchronous call inside of React.useEffect and React.useEffect fires again.

When using fetch, you could abort the request. APromise can be cancelled. But I would recommend against manually writing that code for each component and instead use a library like react-query.

Find me on Twitter @joelnet or YouTube JoelCodes

Cheers 🍻

Comments (2)

Peter Thaleikis's photo

Thanks for writing it up!

Cool cover image, btw!

Joel Thoms's photo

Thanks for reading! Cheers 🍻