DEV Community

Laurin Quast
Laurin Quast

Posted on • Edited on

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

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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?

Given this valid react useEffect usage:

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

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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])

Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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);
})
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
pablomayobre profile image
Pablo Ariel Mayobre

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 of yield fetch

Thanks for this piece of code, really helpful!

Collapse
 
n1ru4l profile image
Laurin Quast

Thanks 🙏 I fixed it!

I have no package published, currently I am just copy pasting it into my projects.

Collapse
 
ancientswordrage profile image
AncientSwordRage

Please do publish it

Thread Thread
 
n1ru4l profile image
Laurin Quast

Finally got around building the package with tests: github.com/n1ru4l/use-async-effect

Thread Thread
 
ancientswordrage profile image
AncientSwordRage

Thank you

Collapse
 
frfancha profile image
Fred • Edited

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 ;-)

Collapse
 
harjis profile image
Joonas Harjukallio

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

Collapse
 
n1ru4l profile image
Laurin Quast

I thought about creating useAsyncCallback. Shouldn‘t be to hard to implement!

Collapse
 
Collapse
 
n1ru4l profile image
Laurin Quast

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 :)

Collapse
 
fsjsd profile image
Chris Webb

Superb dissection of this issue and work!

Collapse
 
benstov profile image
benstov • Edited

Really nice of you to share this! I will definitely use it in dashboard I'm working on :)
Thank you