DEV Community

Cover image for Custom hooks to deal with complex asynchronous code in React
Dmitriy Mozgovoy
Dmitriy Mozgovoy

Posted on

Custom hooks to deal with complex asynchronous code in React

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 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), 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 or cp-fetch:

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


Of course, you can use any async operations inside async effects, not just a single request, right in the same way.

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


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.

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

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:

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


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

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


Another feature is the ability to pause/resume execution:

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


useAsyncCallback has additional options. You can see some of them in the following demo:

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.

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

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.

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

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 😉

Discussion (0)