TLDR
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
The Problem
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 home
useEffect(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.
Why does useEffect
not accept my async functions?
The error message actually gives a clear explanation 😅. Let's break it down!
An async function always returns a
Promise
, thus you cannot synchronously return a cleanup function.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?
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.
Cancelling Pending Async Operations
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?
Introducing useAsyncEffect
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 generator
const instance = generator();
// call next to run the generator until the next yield keyword
let result = instance.next();
console.log(result); // {value: "bars", done: false}
// continue calling
result = instance.next();
console.log(result); // {value: "foo", done: false}
// we can continue calling next until done is true
result = 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 generator
const instance = generator();
let result = instance.next();
console.log(result); // {value: "hello", done: false}
// pass string into generator that will be assigned to the echo variable
instance.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 promise
const 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 comments (12)
This is really cool! Is this published anywhere as a package?
Also you seem to have an error in your last example, you are doing
await fetch
instead ofyield fetch
Thanks for this piece of code, really helpful!
Thanks 🙏 I fixed it!
I have no package published, currently I am just copy pasting it into my projects.
Please do publish it
Finally got around building the package with tests: github.com/n1ru4l/use-async-effect
Thank you
Very useful! Busy to transfer a huge app from angularjs to react and I have implemented your strategy in our fetch calls. Thanks for sharing practical info!
I have also added "sleep" in the API of the server sering data to the app (c# web.api) to test all kind of scenarios and everything was working exactly as expected ;-)
Looks really nice!
Do you know if there is a way to extend this so that it could be executed on demand? What I'm after is a basic save functionality.
What I have been doing so far is just having a callback which calls an async function. The callback is then called from the save buttons onClick handler. This of course doesn't support cleaning up so if the component gets unmounted before saving request has finished it might call setState on an unmounted component
I thought about creating useAsyncCallback. Shouldn‘t be to hard to implement!
[Already Solved] React Hook Warnings for async function in useEffect: useEffect function must return a cleanup function or nothing
completely different solutions and none of them show case aborting effects upon component unmount as well as handling effect re-runs with potential race conditions :)
Superb dissection of this issue and work!
Really nice of you to share this! I will definitely use it in dashboard I'm working on :)
Thank you