9

I want to cancel a promise in my React application using the AbortController and unfortunately the abort event is not recognized so that I cannot react to it.

My setup looks like this:

WrapperComponent.tsx: Here I'm creating the AbortController and pass the signal to my method calculateSomeStuff that returns a Promise. The controller I'm passing to my Table component as a prop.

export const WrapperComponent = () => {
  const controller = new AbortController();
  const signal = abortController.signal;

  // This function gets called in my useEffect
  // I'm passing signal to the method calculateSomeStuff
  const doSomeStuff = (file: any): void => {
    calculateSomeStuff(signal, file)
      .then((hash) => {
        // do some stuff
      })
      .catch((error) => {
        // throw error
      });
  };

  return (<Table controller={controller} />)
}

The calculateSomeStuff method looks like this:

export const calculateSomeStuff = async (signal, file): Promise<any> => {
  if (signal.aborted) {
    console.log('signal.aborted', signal.aborted);
    return Promise.reject(new DOMException('Aborted', 'AbortError'));
  }

  for (let i = 0; i <= 10; i++) {
    // do some stuff
  }

  const secret = 'ojefbgwovwevwrf';

  return new Promise((resolve, reject) => {
    console.log('Promise Started');
    resolve(secret);

    signal.addEventListener('abort', () => {
      console.log('Aborted');
      reject(new DOMException('Aborted', 'AbortError'));
    });
  });
};

Within my Table component I call the abort() method like this:

export const Table = ({controller}) => {
  const handleAbort = ( fileName: string) => {
    controller.abort();
  };

  return (
    <Button
      onClick={() => handleAbort()}
    />
  );
}

What am I doing wrong here? My console.logs are not visible and the signal is never set to true after calling the handleAbort handler.

13
  • Where is doSomeStuff called? Are you sure that it is not being called prior to you setting the abort signal?
    – sma
    Commented Dec 1, 2021 at 16:15
  • 1
    That promise is going to resolve instantly. once it's resolved, rejecting it won't do anything. What is the asynchronous task here? Commented Dec 1, 2021 at 16:19
  • @sma the doSomeStuff method is called within my useEffect hook. I had to simplify the code here :(
    – Codehan25
    Commented Dec 1, 2021 at 16:21
  • @parktomatomi Within the for loop --> await hashChunk(chunk, hasher); is called. The calculated value will be added to the hash constant that is defined below the for loop. Sorry I had to simplify the code a lot :(
    – Codehan25
    Commented Dec 1, 2021 at 16:23
  • 1
    I'm asking because "hashChunk" sounds like a CPU-intensive method where you loop through some data and compute a hash. If you don't do your work inside that Promise constructor, or you don't release the thread at all using await, Worker, or some other means, it won't actually be async. It will just do the work and return a promise that's already resolved. Commented Dec 1, 2021 at 16:34

3 Answers 3

13

Based off your code, there are a few corrections to make:

Don't return new Promise() inside an async function

You use new Promise if you're taking something event-based but naturally asynchronous, and wrap it into a Promise. Examples:

  • setTimeout
  • Web Worker messages
  • FileReader events

But in an async function, your return value will already be converted to a promise. Rejections will automatically be converted to exceptions you can catch with try/catch. Example:

async function MyAsyncFunction(): Promise<number> {
  try {
    const value1 = await functionThatReturnsPromise(); // unwraps promise 
    const value2 = await anotherPromiseReturner();     // unwraps promise
    if (problem)
      throw new Error('I throw, caller gets a promise that is eventually rejected')
    return value1 + value2; // I return a value, caller gets a promise that is eventually resolved
  } catch(e) {
    // rejected promise and other errors caught here
    console.error(e);
    throw e; // rethrow to caller
  }
}

The caller will get a promise right away, but it won't be resolved until the code hits the return statement or a throw.

What if you have work that needs to be wrapped with a Promise constructor, and you want to do it from an async function? Put the Promise constructor in a separate, non-async function. Then await the non-async function from the async function.

function wrapSomeApi() {
  return new Promise(...);
}

async function myAsyncFunction() {
  await wrapSomeApi();
}

When using new Promise(...), the promise must be returned before the work is done

Your code should roughly follow this pattern:

function MyAsyncWrapper() {
  return new Promise((resolve, reject) => {
    const workDoer = new WorkDoer();
    workDoer.on('done', result => resolve(result));
    workDoer.on('error', error => reject(error));
    // exits right away while work completes in background
  })
}

You almost never want to use Promise.resolve(value) or Promise.reject(error). Those are only for cases where you have an interface that needs a promise but you already have the value.

AbortController is for fetch only

The folks that run TC39 have been trying to figure out cancellation for a while, but right now there's no official cancellation API.

AbortController is accepted by fetch for cancelling HTTP requests, and that is useful. But it's not meant for cancelling regular old work.

Luckily, you can do it yourself. Everything with async/await is a co-routine, there's no pre-emptive multitasking where you can abort a thread or force a rejection. Instead, you can create a simple token object and pass it to your long running async function:

const token = { cancelled: false }; 
await doLongRunningTask(params, token); 

To do the cancellation, just change the value of cancelled.

someElement.on('click', () => token.cancelled = true); 

Long running work usually involves some kind of loop. Just check the token in the loop, and exit the loop if it's cancelled

async function doLongRunningTask(params: string, token: { cancelled: boolean }) {
  for (const task of workToDo()) {
    if (token.cancelled)
      throw new Error('task got cancelled');
    await task.doStep();
  }
}

Since you're using react, you need token to be the same reference between renders. So, you can use the useRef hook for this:

function useCancelToken() {
  const token = useRef({ cancelled: false });
  const cancel = () => token.current.cancelled = true;
  return [token.current, cancel];
}

const [token, cancel] = useCancelToken();

// ...

return <>
  <button onClick={ () => doLongRunningTask(token) }>Start work</button>
  <button onClick={ () => cancel() }>Cancel</button>
</>;

hash-wasm is only semi-async

You mentioned you were using hash-wasm. This library looks async, as all its APIs return promises. But in reality, it's only await-ing on the WASM loader. That gets cached after the first run, and after that all the calculations are synchronous.

Even if it is wrapped in an async function or in a function returning a Promise, code must yield the thread to act concurrently, which hash-wasm does not appear to do in its main computation loop.

So how can you let your code breath if you've got CPU intensive code like what hash-wasm uses? You can do your work in increments, and schedule those increments with setTimeout:

for (const step of stepsToDo) {
  if (token.cancelled)
    throw new Error('task got cancelled');

  // schedule the step to run ASAP, but let other events process first
  await new Promise(resolve => setTimeout(resolve, 0));

  const chunk = await loadChunk();
  updateHash(chunk);
}

(Note that I'm using a Promise constructor here, but awaiting immediately instead of returning it)

The technique above will run slower than just doing the task. But by yielding the thread, stuff like React updates can execute without an awkward hang.

If you really need performance, check out Web Workers, which let you do CPU-heavy work off-thread so it doesn't block the main thread. Libraries like workerize can help you convert async functions to run in a worker.


That's everything I have for now, I'm sorry for writing a novel

8
  • Nice novel, good content there! :P
    – Bergi
    Commented Dec 1, 2021 at 23:59
  • Wow! Thank you for this detailed answer. I removed the AbortController. There are some posts that not only use the controller with fetch, but in my case it just didn't work. I then created my own token as you described - now it works :). Thanks!
    – Codehan25
    Commented Dec 2, 2021 at 13:44
  • 1
    I'm sorry but I haven't managed to find any reason why "AbortController is for fetch only". The WHATWG spec clearly outlines a way to integrate an abort controller for custom APIs which seems to be the preferred implementation plan. There's also a Node implementation which hints at the same thing. Commented Dec 15, 2022 at 9:14
  • @NullDivision you're right that I should soften my language. My opinion on AbortController is that it's a DOM API for a DOM problem. And because it utilizes DOM events, it will never be the idiomatic way to cancel promises. Frameworks like React (and browsers, and Node) all have their own solution to "send a signal", so I still think cancelling promises is something that needs to be approached differently depending on the environment. Commented Jan 19, 2023 at 17:00
  • I'm not sure what you mean by this but it seems potentially wrong: "Async code that doesn't actually await doesn't have any benefits. It will not pause to unblock the thread." By definition async code that doesn't await would either be passing callbacks (which generally unblocks the thread, unless something calls the callback synchronously) or registering promise handlers (which definitely unblocks the thread).
    – Andy
    Commented Apr 23 at 16:43
0

I can suggest my library (use-async-effect2) for managing the cancellation of asynchronous tasks/promises. Here is a simple demo with nested async function cancellation:

    import React, { useState } from "react";
    import { useAsyncCallback } from "use-async-effect2";
    import { CPromise } from "c-promise2";
    
    // just for testing
    const factorialAsync = CPromise.promisify(function* (n) {
      console.log(`factorialAsync::${n}`);
      yield CPromise.delay(500);
      return n != 1 ? n * (yield factorialAsync(n - 1)) : 1;
    });
    
    function TestComponent({ url, timeout }) {
      const [text, setText] = useState("");
    
      const myTask = useAsyncCallback(
        function* (n) {
          for (let i = 0; i <= 5; i++) {
            setText(`Working...${i}`);
            yield CPromise.delay(500);
          }
          setText(`Calculating Factorial of ${n}`);
          const factorial = yield factorialAsync(n);
          setText(`Done! Factorial=${factorial}`);
        },
        { cancelPrevious: true }
      );
    
      return (
        <div>
          <div>{text}</div>
          <button onClick={() => myTask(15)}>
            Run task
          </button>
          <button onClick={myTask.cancel}>
            Cancel task
          </button>
        </div>
      );
    }
0

Use custom hooks "useFetchWithCancellation" to handle this.

useFetchWithCancellation.js

import { useState } from 'react';
import { useCallback, useEffect } from 'react';

function fetchWithCancellation(url, options) {
    return new Promise(async (resolve, reject) => {
        try {
            const response = await fetch(url, options);
            resolve(response);
        } catch (error) {
            if (error.name === 'AbortError') {                
                //reject(error);
            } else {
                reject(error);
            }
        }
    });
}

function useFetchWithCancellation(from) {    
    const [controller, setController] = useState(new AbortController());
    useEffect(() => {
        setController(new AbortController());
        return () => {
            return controller.abort();
        };
    }, []);

    const fetchData = useCallback((url, options) => {        
        let opts = {};
        if (options)
            opts = options;
        return fetchWithCancellation(url, { ...opts, signal: controller.signal });
    }, []);
    return {
        fetchData
    }

}
export default useFetchWithCancellation;

MyComponent.js

import useFetchWithCancellation from './useFetchWithCancellation';
const MyComponent = () => {
    const { fetchData } = useFetchWithCancellation();
    callAPI = ()=>{
        fetchData("api", {
                    method: "POST",
                    body: formData
                }).then(response => response.json()).then((res) => {
                    //do something
                });

}
export default MyComponent 
1
  • This seems designed to abort when the component unmounts, but OP was seeking a way to abort when a button is clicked.
    – Andy
    Commented Apr 24 at 14:18

Not the answer you're looking for? Browse other questions tagged or ask your own question.