useAsyncEffect: The Missing React Hook

François Zaninotto
François ZaninottoJanuary 11, 2023
#react#tutorial#popular

Using the useEffect hook to trigger asynchronous side effects is a common React pattern. But it's not as simple as it looks, and more specifically, it's easy to do it wrong and introduce bugs in your application. In this post, I'll explain why, and I'll introduce useAsyncEffect, a React hook that allows you to run asynchronous side effects in React the simple way.

Asynchronous Side Effects in React

Do you think you know how to run asynchronous side effects in React? Let's check your knowledge with a simple example.

Imagine a mail app where you have to mark messages as read when a user opens them. The <Message> component must therefore call useEffect to query the API using fetch or a similar asynchronous function on mount.

You could be tempted to do something like this:

function Message({ message }) {
    const [isRead, setIsRead] = useState(false);
    // don't do this at home
    useEffect(async () => {
        await fetch(`/api/message/${message.id}/read`, { method: 'POST' });
        setIsRead(true);
    }, [message.id]);
    return <div className={isRead ? 'read' : 'unread'}>{message.content}</div>;
}

But useEffect doesn't accept asynchronous callbacks, as an async callback returns a Promise, and the return value of a side effect callback must be the cleanup function. So you have to do something like this instead:

function Message({ message }) {
    const [isRead, setIsRead] = useState(false);
    useEffect(() => {
        const markAsRead = async () => {
            await fetch(`/api/message/${message.id}/read`, { method: 'POST' });
            setIsRead(true);
        };
        markAsRead();
    }, [message.id]);
    return <div className={isRead ? 'read' : 'unread'}>{message.content}</div>;
}

Or, using an immediately invoked function expression (IIFE):

function Message({ message }) {
    const [isRead, setIsRead] = useState(false);
    useEffect(() => {
        (async () => {
            await fetch(`/api/message/${message.id}/read`, { method: 'POST' });
            setIsRead(true);
        })();
    }, [message.id]);
    return <div className={isRead ? 'read' : 'unread'}>{message.content}</div>;
}

Race Conditions With Data Fetching

The above code has a flaw: it is vulnerable to a race condition. If the message.id changes, the effect will run twice. And if the first call to fetch() returns before the second call, the second message will be marked as read too soon, potentially giving wrong information to the user. React has a built-in solution for that: an ignore variable in the cleanup function. The idea is to prevent the setIsRead(true) call from happening if the message.id changes before the fetch() call returns by introducing a local variable.

function Message({ message }) {
    const [isRead, setIsRead] = useState(false);
    useEffect(() => {
        // this variable is local to the effect, and will be set to true when the effect is cleaned up
        let ignore = false;
        (async () => {
            await fetch(`/api/message/${message.id}/read`, { method: 'POST' });
            if (ignore) {
                // if the message id changed, we don't want to mark the message as read
                return;
            }
            setIsRead(true);
        })();
        return () => {
            // on cleanup, prevent the setIsRead(true) call from happening
            ignore = true;
        };
    }, [message.id]);
    return <div className={isRead ? 'read' : 'unread'}>{message.content}</div>;
}

Problem solved? No, we're not yet at the bottom of the rabbit hole.

Strict Mode And Double Fetching

This code doesn't work in React Strict Mode. In this mode, React mounts the component, then immediately unmount it, then mounts it again. This means that in development, the <Message> component above will call the fetch() function twice, creating two "reads" on the same message. Most backends will probably return a failed response for the second call, effectively breaking your application.

The React docs explain that:

In development, you will see two fetches in the Network tab. There is nothing wrong with that.

Allow me to disagree. Strict Mode will make this "mark as read" feature completely unusable in development.

So we need to delay the call to fetch() until the component is actually mounted for real. We can do that by waiting for the next tick:

function Message({ message }) {
    const [isRead, setIsRead] = useState(false);
    useEffect(() => {
        let ignore = false;
        (async () => {
            // wait for the initial unmount in Strict mode - avoids double mutation
            await Promise.resolve();
            if (ignore) {
                return;
            }
            await fetch(`/api/message/${message.id}/read`, { method: 'POST' });
            setIsRead(true);
        })();
        return () => {
            ignore = true;
        };
    }, [message.id]);
    return <div className={isRead ? 'read' : 'unread'}>{message.content}</div>;
}

This code begins to be hard to read...

Cleanup Side Effects

This gets even more complex when we need an unmount side effect. For instance, imagine a collaborative content management system, where you want to lock a resource when a user opens it, and unlock the resource when they close it.

The unlock call must be done in the cleanup side effect:

function EditContent({ content }) {
    const [isLocked, setIsLocked] = useState(false);
    useEffect(() => {
        let ignore = false;
        (async () => {
            await Promise.resolve();
            if (ignore) {
                return;
            }
            await fetch(`/api/content/${content.id}/lock`, { method: 'POST' });
            setIsLocked(true);
        })();
        return () => {
            ignore = true;
            // problems ahead...
            (async () => {
                await fetch(`/api/content/${content.d}/unlock`, {
                    method: 'POST',
                });
                setIsLocked(false);
            })();
        };
    }, [content.id]);
    return (
        <div className={isLocked ? 'locked' : 'unlocked'}>{content.body}</div>
    );
}

In React Strict mode, the cleanup side effect will run immediately after the initial effect, which is before the async call to fetch returns. This means that the fetch to unlock may be called before the lock call succeeds, which is clearly undesirable.

We need to run the cleanup side effect only if the first one actually succeeded (i.e. we don't want to unlock a resource that was never locked). We can do that by using a second local variable:

function EditContent({ content }) {
    const [isLocked, setIsLocked] = useState(false);
    useEffect(() => {
        let ignore = false;
        // keep track of the completion of the initial side effect
        let mountSucceeded = false;
        (async () => {
            await Promise.resolve();
            if (ignore) {
                return;
            }
            await fetch(`/api/content/${content.id}/lock`, { method: 'POST' });
            setIsLocked(true);
            mountSucceeded = true; // allow the unmount side effect to run
        })();
        return () => {
            if (ignore || !mountSucceeded) {
                // if the initial side effect didn't succeed, don't run the cleanup side effect
                return;
            }
            ignore = true;
            (async () => {
                await fetch(`/api/content/${content.d}/unlock`, {
                    method: 'POST',
                });
                setIsLocked(false);
            })();
        };
    }, [content.id]);
    return (
        <div className={isLocked ? 'locked' : 'unlocked'}>{content.body}</div>
    );
}

That's a lot of boilerplate code for a seemingly simple feature, don't you think?

Setting State On An Unmounted Component

The setIsLocked may be called on an unmounted component, and this can lead to React warnings. We need to keep track of the mounted state using a ref, and only call setIsLocked if the component is still mounted:

function EditContent({ content }) {
    const [isLocked, setIsLocked] = useState(false);

    const mounted = useRef(false);
    useEffect(() => {
        mounted.current = true;
        return () => {
            mounted.current = false;
        };
    }, []);

    useEffect(() => {
        let ignore = false;
        let mountSucceeded = false;
        (async () => {
            await Promise.resolve();
            if (ignore) {
                return;
            }
            await fetch(`/api/content/${content.id}/lock`, { method: 'POST' });
            if (mounted.current) {
                setIsLocked(true);
            }
            mountSucceeded = true;
        })();
        return () => {
            if (ignore || !mountSucceeded) {
                return;
            }
            ignore = true;
            (async () => {
                await fetch(`/api/content/${content.d}/unlock`, {
                    method: 'POST',
                });
                if (mounted.current) {
                    setIsLocked(false);
                }
            })();
        };
    }, [content.id]);

    return (
        <div className={isLocked ? 'locked' : 'unlocked'}>{content.body}</div>
    );
}

Now that's getting a bit out of hand...

Introducing The useAsyncEffect Hook

Asynchronous side effects in React are hard. I've been writing React for years, and I struggle with this kind of code every time I have to write it. React forces me to a lot of boilerplate code, when all I want to do is:

useAsyncEffect(mainSideEffect, cleanupSideEffect, dependencies);

With that hook, I could write the previous example as:

function EditContent({ content }) {
    const [isLocked, setIsLocked] = useState(false);
    useAsyncEffect(
        async () => {
            await fetch(`/api/content/${content.id}/lock`, { method: 'POST' });
            setIsLocked(true);
        },
        async () => {
            await fetch(`/api/content/${content.d}/unlock`, { method: 'POST' });
            setIsLocked(false);
        },
        [content.id],
    );
    return (
        <div className={isLocked ? 'locked' : 'unlocked'}>{content.body}</div>
    );
}

Let's write that useAsyncEffect hook!

import { useEffect, useState, useMemo, useRef } from 'react';

/**
 * Hook to run an async effect on mount and another on unmount.
 */
export const useAsyncEffect = (
    mountCallback: () => Promise<any>,
    unmountCallback: () => Promise<any>,
    deps: any[] = [],
): UseAsyncEffectResult => {
    const isMounted = useRef(false);
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState<unknown>(undefined);
    const [result, setResult] = useState<any>();

    useEffect(() => {
        isMounted.current = true;
        return () => {
            isMounted.current = false;
        };
    }, []);

    useEffect(() => {
        let ignore = false;
        let mountSucceeded = false;

        (async () => {
            await Promise.resolve(); // wait for the initial cleanup in Strict mode - avoids double mutation
            if (!isMounted.current || ignore) {
                return;
            }
            setIsLoading(true);
            try {
                const result = await mountCallback();
                mountSucceeded = true;
                if (isMounted.current && !ignore) {
                    setError(undefined);
                    setResult(result);
                    setIsLoading(false);
                } else {
                    // Component was unmounted before the mount callback returned, cancel it
                    unmountCallback();
                }
            } catch (error) {
                if (!isMounted.current) return;
                setError(error);
                setIsLoading(false);
            }
        })();

        return () => {
            ignore = true;
            if (mountSucceeded) {
                unmountCallback()
                    .then(() => {
                        if (!isMounted.current) return;
                        setResult(undefined);
                    })
                    .catch((error: unknown) => {
                        if (!isMounted.current) return;
                        setError(error);
                    });
            }
        };
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, deps);

    return useMemo(() => ({ result, error, isLoading }), [
        result,
        error,
        isLoading,
    ]);
};

export interface UseAsyncEffectResult {
    result: any;
    error: any;
    isLoading: boolean;
}

My useAsyncEffect contains a bit more logic than required by the lock/unlock example, as it allows us to get the result from the mount effect, and keep track of the loading state and the error state. It also allows to cancellation of the unmount callback if the mount callback is still running when the component is unmounted.

This code is free to use - I hope it will help you write better React apps!

Conclusion

The complexity of asynchronous side effects in React is, in my opinion, the biggest pain point of the framework. Just look at the "Caveats" section of the official useEffect documentation:

useEffect caveats

I think this complexity should be handled by the framework, not left to the developer. As I wrote in a previous article, the mental model of useEffect is not straightforward, and it's too easy to make mistakes.

I hope that the useAsyncEffect hook will help you write better React code and that it will be integrated into React one day.

And if you want to write React apps without the pain, consider using React Admin, the frontend framework for building admin and B2B apps that my company, marmelab, develops and maintains.

Did you like this article? Share it!