Homebrew React Hooks: useAsyncEffect Or How to Handle Async Operations with useEffect
Tags: react, webdev, javascript
Published: 2019-07-05 11:41:23

# 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`](https://github.com/n1ru4l/use-async-effect)

# 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

```js
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! 

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?

{% twitter 1146879490347470848 %}

Given this valid react `useEffect` usage:

```js
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`).

```js
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:

```js
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.

```js
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](https://www.npmjs.com/package/abortcontroller-polyfill). 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:

```js
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?

```js
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!

```js
// 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:

```js
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)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function*#Specifications)

Async functions were not part of the spec until [ECMAScript 2017 (ECMA-262)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function#Specifications) 

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`](https://www.npmjs.com/package/co)

```js
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

```js
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

```js
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.

- [Dev.to](https://dev.to/n1ru4l)
- [Medium](https://medium.com/@n1ru4l)
- [Twitter](https://twitter.com/n1rual)
- [Github](https://github.com/n1ru4l)

Have an awesome and productive day!