Custom hooks to deal with complex asynchronous code in React
Tags: javascript, react, webdev, axios
Published: 2021-05-23 20:25:59

You've probably encountered asynchronous tasks many times inside React components. A typical approach is to split a complex asynchronous task into multiple synchronous ones, wrap them with useEffect, and synchronize those effects through dependencies. In very simple cases, this is not that difficult and expensive, although our component is re-rendered every time we change state, even if some state variables are not used in JSX rendering. But when the task gets more complex, we have to worry about these unnecessary re-renders, using the useMemo hook and other techniques. We can't just use asynchronous functions inside components as we cannot get state updates during its execution due to the nature of JavaScript closures. Moreover, we have to cancel the running asynchronous tasks when components unmount or their dependencies change to avoid the React leak warning that everyone has certainly encountered many times:

>Warning: Can't perform a React state update on an unmounted component.

In search of a solution, the [use-async-effect2](https://www.npmjs.com/package/use-async-effect2) library was written, which provides several hooks that can work with asynchronous code. They work on top of cancellable promises provided by another one of my projects with cancellable promise ([c-promise2](https://www.npmjs.com/package/c-promise2)), synchronized with React component lifecycle. All async routines are cancellable, so they can be automatically canceled when the component unmounts, or when effect dependency changed, after a timeout, or by the user's request.
The library provides four hooks: 
- `useAsyncEffect`
- `useAsyncCallback`
- `useAsyncDeepState`
- `useAsyncWatcher`

## useAsyncEffect

Using `useAsyncEffect` or `useAsyncCallback` it becomes trivially to make a cancellable request with [cp-axios](https://www.npmjs.com/package/cp-axios) or [cp-fetch](https://www.npmjs.com/package/cp-fetch):

```javascript
import React from "react";
import { useAsyncEffect } from "use-async-effect2";
import cpAxios from "cp-axios";

/*
 Note: the related network request will also be aborted
 when the component unmounts or on user request
 Check out your network console
 */

function TestComponent(props) {
  const [cancel, done, result, err] = useAsyncEffect(
    function* () {
      return (yield cpAxios(props.url).timeout(props.timeout)).data;
    },
    { states: true, deps: [props.url] }
  );

  return (
    <div className="component">
      <div className="caption">useAsyncEffect demo:</div>
      <div>
        {done ? (err ? err.toString() : JSON.stringify(result)) : "loading..."}
      </div>
      <button className="btn btn-warning" onClick={cancel} disabled={done}>
        Cancel async effect
      </button>
    </div>
  );
}
```

{% codesandbox use-async-effect-fetch-tiny-ui-states-axios-g5qd7 %}
Of course, you can use any async operations inside async effects, not just a single request, right in the same way.

```javascript
  const [cancel, done, result, err] = useAsyncEffect(
    function* () {
      this.timeout(props.timeout);
      const data = (yield cpAxios(
        "https://run.mocky.io/v3/39486170-1983-457b-a89f-b0736ccf7961?mocky-delay=2s"
      )).data;
      return (yield cpAxios(
        `https://rickandmortyapi.com/api/character/${data.fetchId}`
      )).data;
    },
    { states: true }
  );
```
{% codesandbox use-async-effect-several-requests-axios-t0wl9 %}
Here is a simple demo of requesting weather data (https://openweathermap.org/ through a proxy on Heroku to hide API key) according to the geo-coordinates of the user. See the full source code in the sandbox below.

```javascript
const [cancel, done, result, err] = useAsyncEffect(
    function* () {
      this.timeout(30000);
      const {
        coords: { latitude, longitude }
      } = yield getCurrentPosition();
      const response = yield cpFetch(
        `https://blooming-river-02929.herokuapp.com/weather/loc?lat=${latitude}&lon=${longitude}`
      ).timeout(props.timeout);
      return yield response.json();
    },
    { states: true }
  );
```

{% codesandbox use-async-effect-weather-ui-forked-mutop %}

## useAsyncCallback

Probably, the best way to find out how the hook works is to implement a typical challenge- Live Search. Let's do it using `rickandmortyapi.com` as our data source:

```javascript
import React, { useState } from "react";
import {
  useAsyncCallback,
  E_REASON_UNMOUNTED,
  CanceledError
} from "use-async-effect2";
import { CPromise } from "c-promise2";
import cpAxios from "cp-axios";

export default function TestComponent(props) {
  const [text, setText] = useState("");

  const search = useAsyncCallback(
    function* (value) {
      if (value.length < 3) return;
      yield CPromise.delay(1000);
      setText("searching...");
      try {
        const response = yield cpAxios(
          `https://rickandmortyapi.com/api/character/?name=${value}`
        ).timeout(props.timeout);
        setText(response.data?.results?.map(({ name }) => name).join(","));
      } catch (err) {
        CanceledError.rethrow(err, E_REASON_UNMOUNTED);
        setText(err.response?.status === 404 ? "Not found" : err.toString());
      }
    },
    { cancelPrevious: true }
  );

  return (<JSX/>)
}
```
{% codesandbox use-async-effect-axios-rickmorty-search-ui-states-6ww2d %}
When you catch any errors with a `try...catch` block, you must ensure that the caught error is not a CanceledError with reason `E_REASON_UNMOUNTED` by adding the following line to the beginning of the `catch` block:

>CanceledError.rethrow(err, E_REASON_UNMOUNTED);

The error indicates that the component has been unmounted or its dependencies have changed, so you should simply rethrow the error. This prevents unwanted code execution on unmounted components and protects against React leak warnings appearing.
You can capture the progress and subscribe to the internal `AbortController`, which has every CPromise instance (useAsyncEffect and useAsyncCallback are running their generator functions in the context of CPromise instance).

```javascript
  const [cancel, done, result, err] = useAsyncEffect(
    function* () {
      this.progress(setProgress);
      this.signal.addEventListener("abort", () =>
        console.log("controller aborted")
      );
      yield CPromise.delay(15000);
      return "Hello!";
    },
    { states: true }
  );
```
{% codesandbox use-async-effect-abortcontroller-progress-8yugb %}
Another feature is the ability to pause/resume execution:

```javascript
function TestComponent(props) {
  const [text, setText] = useState("one two three four five");
  const [word, setWord] = useState("");

  const go = useAsyncCallback(
    function* (text, delay) {
      const words = text.split(/\s+/);
      for (const word of words) {
        setWord(word);
        yield CPromise.delay(delay);
      }
    },
    { states: true, cancelPrevios: true }
  );
  return (<div>
       <button onClick={go}>Run</button>
       <button onClick={go.pause}>Pause</button>
       <button onClick={go.resume}>Resume</button>
       <button onClick={go.cancel}>Cancel</button>
     </div>
  )
}
```
{% codesandbox use-async-callback-pause-resume-cancel-op3cy %}
`useAsyncCallback` has additional options. You can see some of them in the following demo:
{% codesandbox use-async-callback-demo-app-yyic4 %}
## useAsyncDeepState

`useAsyncDeepState` is a deep state implementation (similar to this.setState(patchObject)) whose setter can return a promise synchronized with the internal effect. If the setter is called with no arguments, it does not change the state values, but simply subscribes to state updates. In this case, you can get the state value from anywhere inside your component, since function closures will no longer be a hindrance. It is primarily intended for use inside asynchronous functions.

```javascript
const delay=(ms)=> new Promise((resolve) => setTimeout(resolve, ms));

function TestComponent(props) {
  const [state, setState] = useAsyncDeepState({
    counter: 0,
    computedCounter: 0
  });

  useEffect(() => {
    setState(({ counter }) => ({
      computedCounter: counter * 2
    }));
  }, [state.counter]);

  const inc = useCallback(() => {
    (async () => {
      await delay(1000);
      await setState(({ counter }) => ({ counter: counter + 1 }));
      console.log("computedCounter=", state.computedCounter);
    })();
  });

  return (<JSX/>);
}
```
{% codesandbox use-async-deep-state-5c9d0 %}

## useAsyncWatcher

`useAsyncWatcher(...values):watcherFn(peekPrevValue: boolean)=>Promise` - is a promise wrapper around useEffect that can wait for updates, return a new value and optionally a previous one if the optional `peekPrevValue` argument is set to true.

```javascript
function TestComponent(props) {
  const [counter, setCounter] = useState(0);
  const [text, setText] = useState("");

  const textWatcher = useAsyncWatcher(text);

  useEffect(() => {
    setText(`Counter: ${counter}`);
  }, [counter]);

  const inc = useCallback(() => {
    (async () => {
      await delay(1000);
      setCounter((counter) => counter + 1);
      const updatedText = await textWatcher();
      console.log(updatedText);
    })();
  }, []);

  return (<JSX/>);
}
```
{% codesandbox use-async-watcher-njuoc %}
> Note: `useCallback` was used instead of `useAsyncCallback` to keep the focus on `useAsyncWatcher` & `useAsyncDeepState` hooks.

When these hooks used in conjunction with cancellable `CPromise` methods (`.all`, `.race`, `.allSettled`, `.retry`, `.delay`, `.promisify`), you get powerful tools for executing asynchronous code.

Thank you for reading, it is very nice to know that there are such strong-willed people who were able to reach the end 😉