Back

Writing a Promise Hook

Using Promises in React with hooks or with classes isn't as easy as it might seem at first. Let's look at a simple example to illustrate the problem:

const [result, setResult] = useState<string | undefined>(undefined);
useEffect(() => {
  promiseReturningFunction(a).then((res) => setResult(res));
}, [a]);

This code might not do what you want it to do - what's the problem with this implementation?

1.

Let's suppose that a is 1 at first, the request is sent and takes 1000ms, but a is changed immediately to 2, therefore another request is sent and that one could return before the first one. Therefore the first request which returns after the second one and will override the value that is associated with 2. That would result in the result of the a = 1 request being displayed although a currently is 2.

a = 1   a = 2   setResult(2)  setResult(1)       result = 1, a = 2 ?!?
  |       \----------/             |
  \--------------------------------/

2.

There is also another error which you might experience when using a dev build of react: A state update on an unmounted component (It's also a problem in if you are using a prod build of react but it won't notify you). When the component is unmounted while a promise is still pending the .then will call setResult although the component is no longer mounted:

request:       |------| setResult
component: |------| unmounted

The solution is quite simple: We have to "cancel" the request when the effect is supposed to do it's cleanup. Ok how can we achieve that? useRef to store the promise? - sadly not because there is no generic way to cancel a promise. What about a useRef to store a boolean variable called cancelled? Better but that will only handle the second problem.

A simple variable scoped to the effect function will do the trick:

const [result, setResult] = useState<string | undefined>(undefined);
useEffect(() => {
  let cancel = false;
  promiseReturningFunction(a).then((res) => {
    if (cancel) return;
    setResult(res);
  });
  return () => {
    cancel = true;
  };
}, [a]);

Okay but that seems like a lot of code to write every time you want to consume some async function, it might be a better idea to extract this logic into a custom hook - let's call it useAsync.

Let's think about the parameters that such a hook could have:

  • fn: () => Promise<T> (the function to call)
  • deps: any[] (the deps of useEffect)
const useAsync = <T>(fn: () => Promise<T>, deps: any[]) => {
  const [res, setRes] = useState<T | undefined>();
  useEffect(() => {
    let cancel = false;
    fn().then((res) => {
      if (cancel) return;
      setRes(res);
    });
    return () => {
      cancel = true;
    };
  }, deps);
  return res;
};

Usage

const result = useAsync(() => fn(a), [a]);

But it seems like at least two things are missing: a loading state and error handling - let's add them:

const useAsync = <T>(fn: () => Promise<T>, deps: any[]) => {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<Error | undefined>();
  const [res, setRes] = useState<T | undefined>();
  useEffect(() => {
    setLoading(true);
    let cancel = false;
    fn().then(
      (res) => {
        if (cancel) return;
        setLoading(false);
        setRes(res);
      },
      (error) => {
        if (cancel) return;
        setLoading(false);
        setError(error);
      }
    );
    return () => {
      cancel = true;
    };
  }, deps);
  return { loading, error, res };
};

A proper solution would use different ways to cancel the promise, e.g. AbortController in the case of fetch. The problem here is not just limited to hooks. Class components in React have the same problem, but it mostly is ignored. It example shows that hooks are great for generically describing behavior without a lot of copy-pasting.