Homebrew React Hooks: useAsyncEffect Or How to Handle Async Operations with useEffect (2023)

Async functions lack cancelability. We can use generator functions for mimicking cancelable async functions. I created a library for writing async effects: useAsyncEffect on Github

Most of us love working with the async-await syntax!

Some of you (including me) might have tried executing the following piece of code

import { useState, useEffect } from "react";const [state, setState] = useState()// do not try this at homeuseEffect(async () => { const data = await fetchSomeData() setState(data);}, []);

And those who did so might also have noticed that this piece of code will print a big error message into the developer console:

Warning: An Effect function must not return anything besides a function, which is used for clean-up.It looks like you wrote useEffect(async () => ...) or returned a Promise. Instead, you may write an async function separately and then call it from inside the effect:async function fetchComment(commentId) { // You can await here}useEffect(() => { fetchComment(commentId);}, [commentId]);In the future, React will provide a more idiomatic solution for data fetching that doesn't involve writing effects manually.

The error message actually gives a clear explanation 😅. Let's break it down!

  1. An async function always returns a Promise, thus you cannot synchronously return a cleanup function.

  2. React calls the cleanup function when one of the dependencies of useEffect changes or the component unmounts.

Even if useEffect would support resolving cleanup functions from a Promise, that change could happen before the Promise has resolved (or even worse, rejected). As a result, the cleanup function would either be called too late or never.

Why would I even need a cleanup function anyways?

Homebrew React Hooks: useAsyncEffect Or How to Handle Async Operations with useEffect (1)

Dan Abramov

@dan_abramov

Homebrew React Hooks: useAsyncEffect Or How to Handle Async Operations with useEffect (2)

Ever wondered why you can’t put async function directly as useEffect argument, and have to put it inside instead? It’s not a technical limitation. It’s to help guide you towards code that handles race conditions well. twitter.com/dan_abramov/st…

20:32 PM - 04 Jul 2019

Dan Abramov @dan_abramov

@thepaulmcbride @chrislaughlin There’s a good reason though. If your effect function was “async”, that would leave you no opportunity to ignore stale fetch results (or cancel fetch) 🙂 https://t.co/N8SE6BkbmC

Homebrew React Hooks: useAsyncEffect Or How to Handle Async Operations with useEffect (3) Homebrew React Hooks: useAsyncEffect Or How to Handle Async Operations with useEffect (4) Homebrew React Hooks: useAsyncEffect Or How to Handle Async Operations with useEffect (5)

Given this valid react useEffect usage:

const [data, setData] = useState();useEffect(() => { const runEffect = async () => { const data = await fetchSomeData(filter); setData(data); }; runEffect();}, [setData, filter]);

Let's assume that the component unmounts while the fetchSomeData promise is still unresolved. That would mean setData is called despite the component already being unmounted.

You might remember the Can't call setState (or forceUpdate) on an unmounted component. warning from Class Components, this still applies to hooks.

Even worse, when the filter dependency changes before fetchSomeData resolves we have two race conditions colliding. What if for some reason the second fetchSomeData promise resolves before the first fetchSomeData promise? In that case, the "newer" data will be overwritten by the "old" data once the delayed promise has resolved 😲.

How exactly do we prevent such issues?

Async-Await is not perfect

In an ideal world, we would not have to care about such things, but unfortunately, it is not possible to cancel an async function. Which means we have to check whether the current useEffect cycle has ended after each async operation (Promise).

const [data, setData] = useState();useEffect(() => { let cancel = false; const runEffect = async () => { const data = await fetchSomeData(filter); if (cancel) { return; } setData(data); }; runEffect(); // Cleanup function that will be called on // 1. Unmount // 2. Dependency Array Change return () => { cancel = true; }}, [setData, filter]);

This can become very tedious in an async function that does many awaits in sequence:

const [data1, setData1] = useState();const [data2, setData2] = useState();const [data3, setData3] = useState();useEffect(() => { let cancel = false; const runEffect = async () => { const data1 = await fetchSomeData(filter); if (cancel) { return; } setData1(data); const data2 = await fetch(data1.url); if (cancel) { return; } setData2(data); const data3 = await fetch(data2.url); if (cancel) { return; } setData3(data); }; runEffect(); // Cleanup function that will be called on // 1. Unmount // 2. Dependency Array Change return () => { cancel = true; }}, [setData1, setData2, setData3, filter]);

This is the only way we can ensure setState is not called after the cleanup function has been called, nevertheless, the async operation aka the network request (initiated through fetch) is still being executed.

Modern Browers come with a new API called AbortController which can be used for aborting pending fetch requests.

const [data, setData] = useState();useEffect(() => { const controller = new AbortController(); const runEffect = async () => { try { const data = await fetch( "https://foo.bars/api?filter=" + filter, { signal: controller.signal } ); setData(data); } catch (err) { if (err.name === 'AbortError') { console.log("Request was canceled via controller.abort"); return; } // handle other errors here } }; runEffect(); return () => { controller.abort(); }}, [setData, filter]);

Now every time filter changes or the component is updated the pending network request is aborted. Instead of resolving, the fetch Promise will reject with an error 👌.

You can learn about browser support for AbortController here (of course IE does not support AbortController 😖): https://caniuse.com/#feat=abortcontroller

There is a polyfill available. It does not actually implement canceling since it must be done natively in the browser. Instead, it mimics the behavior by throwing an abort error after the fetch call has resolved/rejected.

Furthermore, this solution only works for fetch calls 😕.
Some API's provide ways of canceling async operations, others do not.

For instance, this is how you can cancel loading an Image with a useEffect hook today:

export const loadImage = src => { const image = new Image(); const done = false; const cancel = () => { if (done) { // do not change the image instance once it has been loaded return; } // this will abort the request and trigger the error event image.src = ""; }; const promise = new Promise((resolve, reject) => { image.src = src; const removeEventListeners = () => { image.removeEventListener("load", loadListener); image.removeEventListener("error", errorListener); }; const loadListener = () => { removeEventListeners(); done = true; resolve(image); }; const errorListener = err => { removeEventListeners(); reject(err); }; image.addEventListener("load", loadListener); image.addEventListener("error", errorListener); }); return { promise, cancel };};useEffect(() => { const task = loadImage(url) const runEffect = async () => { try { const image = await task.promise; // do sth with image } catch (err) { // handle cancel error } }; runEffect(); return () => { task.cancel(); }}, [url])

In an environment where you are working with other uncancelable async API's, you will still have to set and check a boolean variable.

Hopefully, all async based APIs will someday support using the AbortController.

For now, we have to handle a mix of boolean checks and try catches.

But what if we could have some abstraction over both canceling requests and stopping function execution after an await keyword?

Have you heard about Generator Functions before?

const generator = function *() { yield "bars"; yield "foo"; return "fizz"}

A generator function is a pausable function. The yield keyword indicates a pause of the function. Let's run this generator!

// create instance of generatorconst instance = generator();// call next to run the generator until the next yield keywordlet result = instance.next();console.log(result); // {value: "bars", done: false}// continue callingresult = instance.next();console.log(result); // {value: "foo", done: false}// we can continue calling next until done is trueresult = instance.next();console.log(result); // {value: "fizz", done: true}

Besides passing values out of the generator, we can also pass in values as an argument of the next method:

const generator = function *() { const echo = yield "hello"; console.log(echo);}// create instance of generatorconst instance = generator();let result = instance.next();console.log(result); // {value: "hello", done: false}// pass string into generator that will be assigned to the echo variableinstance.next("hello generator");

This is pretty cool! But how can this help us with the async-await issue?

In the past generators have been used to simulate async-await behaviour

Generators have been around since ECMAScript 2015 (6th Edition, ECMA-262)

Async functions were not part of the spec until ECMAScript 2017 (ECMA-262)

During the period between EcmaScript 2015 and 2017 various libraries that mimicked the behaviour of async-await with generators popped up.

One of the most popular ones being co

import co from 'co';// wrap generator into function that returns a promiseconst asyncFunction = co.wrap(function * () { const result = yield fetch(url); console.log(result); return 1});asyncFunction().then((res) => { assert.equal(res, 1);})

Co does basically run the generator until a promise is yield-ed, then waits for the promise resolving and continues running the generator with the resolved value of the promise (get.next(resolvedPromiseValue)) until the generator is done (gen.next(resolvedPromiseValue).done === true).

One thing that distinguishes async-await and generators (besides their syntax), is that generators are not forced into resolving a Promise or even continuing execution of the generator function after it has paused.

Which basically means we can use a generator as a "cancelable" async-await.

Let's built that useAsyncEffect hook

Implementation

import { useEffect } from "react";const noop = () => {}const useAsyncEffect = (generator, deps = []) => { // store latest generator reference const generatorRef = useRef(generator); generatorRef.current = generator; useEffect(() => { let ignore = false; let onCancel = noop; const runGenerator = async () => { // create generator instance const instance = generatorRef.current(_onCancel => { // allow specifying a onCancel handler // that can be used for aborting async operations // e.g. with AbortController // or simple side effects like logging // For usage: see example below onCancel = _onCancel || noop; }); // generator result let res = { value: undefined, done: false }; do { res = instance.next(res.value); try { // resolve promise res.value = await res.value; } catch (err) { try { // generator also allow triggering a throw // instance.throw will throw if there is no // try/catch block inside the generator function res = instance.throw(err); } catch (err) { // in case there is no try catch around the yield // inside the generator function // we propagate the error to the console console.error("Unhandeled Error in useAsyncEffect: ", err); } } // abort further generator invocation on // 1. Unmount // 2. Dependency Array Change if (ignore) { return; } } while (res.done === false); }; runGenerator(); // Cleanup function that will be called on // 1. Unmount // 2. Dependency Array Change return () => { ignore = true; onCancel(); }; }, deps);};

Usage

const [data, setData] = useState();useAsyncEffect(function * (onCancel) { const controller = new AbortController(); // handle error  onCancel(() => { console.log("cancel while fetch is still executed, use controller for aborting the request."); controller.abort(); }); try { const data = yield fetch( "https://foo.bars/api?filter=" + filter, { signal: controller.signal } ) setData(data); } catch (err) { if (err.name === 'AbortError') { console.log("Request was canceled via controller.abort") // we know that an 'AbortError' occurs when the request is // cancelled this means that the next promise returned by yield // will be created but not actively used, thus, we return in // order to avoid the promise being created. return; } } // set new cancel handler onCancel(() => { console.log("cancel while doSthAsyncThatIsNotCancelable is still being executed"); }); const newData = yield doSthAsyncThatIsNotCancelable(); setData(newData); // all our async operations have finished // we do not need to react to anything on unmount/dependency change anymore onCancel(() => { console.log("everything ok"); })}, [setData, filter]);

This hook now allows us to omit all the boolean checks (ignore === true) in our component while still giving us the power to cancel async operations (that are cancelable) or handling other side-effects by registering a handler function with onCancel.

I hope you enjoyed reading this!

Have you used generators before? How do you handle async operations with useEffect today? Will you use the useAsyncEffect hook in your code? Do you have any feedback or spotted a bug?

Let's discuss in the comments!

Also, feel free to follow me on these platforms, if you enjoyed this article I ensure you that a lot more awesome content will follow. I write about JavaScript, Node, React and GraphQL.

Have an awesome and productive day!

Top Articles
Latest Posts
Article information

Author: Kerri Lueilwitz

Last Updated: 14/12/2023

Views: 6250

Rating: 4.7 / 5 (47 voted)

Reviews: 86% of readers found this page helpful

Author information

Name: Kerri Lueilwitz

Birthday: 1992-10-31

Address: Suite 878 3699 Chantelle Roads, Colebury, NC 68599

Phone: +6111989609516

Job: Chief Farming Manager

Hobby: Mycology, Stone skipping, Dowsing, Whittling, Taxidermy, Sand art, Roller skating

Introduction: My name is Kerri Lueilwitz, I am a courageous, gentle, quaint, thankful, outstanding, brave, vast person who loves writing and wants to share my knowledge and understanding with you.