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!
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?
Dan Abramov
@dan_abramov
![]()
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
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!