Skip to content
easy-cancelable-promise

cancel and onCancel

Cancel a promise from anywhere, stop its work with onCancel, pass a reason, remove listeners and tell a cancellation from a failure.

GuideTypeScriptTry an example

Two halves of one feature

cancel says “nobody needs this result any more”. onCancel is where the promise reacts to that. A promise without an onCancel listener is still canceled, but whatever it started keeps running.

Clean up inside the promise

Register the cleanup in the callback, next to the thing that needs it.

on-cancel-inside.ts
import { CancelablePromise } from 'easy-cancelable-promise';

export const openSocket = (url: string) =>
  new CancelablePromise<string>((resolve, reject, { onCancel }) => {
    const socket = new WebSocket(url);

    // the promise owns its cleanup: whoever cancels it does not need to know about the socket
    onCancel(() => socket.close());

    socket.addEventListener('message', (event) => resolve(String(event.data)), { once: true });
    socket.addEventListener('error', () => reject(new Error('Connection failed')), { once: true });
  });

Whoever cancels the promise does not need to know there is a socket. That is the point: the knowledge of how to stop the work stays with the code that started it.

Listen from outside

onCancel is also a method of the promise. It returns the promise, so listeners can be chained. The listeners registered inside the callback run first.

on-cancel-outside.ts
import { CancelablePromise } from 'easy-cancelable-promise';

export function listen() {
  const calls: string[] = [];

  const task = new CancelablePromise<void>((_resolve, _reject, { onCancel }) => {
    // listeners of the promise itself run first
    onCancel(() => calls.push('inside'));
  });

  // any number of listeners can be added from outside, and they can be chained
  task
    .onCancel((reason) => calls.push(`outside: ${String(reason)}`))
    .onCancel(() => calls.push('analytics'));

  task.cancel('User closed the dialog');

  return calls; // ['inside', 'outside: User closed the dialog', 'analytics']
}

The reason

cancel accepts a reason. The promise rejects with it, and every listener receives it.

reason.ts
import { CancelablePromise } from 'easy-cancelable-promise';

const forever = () => new CancelablePromise<void>(() => {});

export async function reasons() {
  // without a reason the promise rejects with new Error('Promise canceled')
  const byDefault = await forever()
    .cancel()
    .catch((reason) => (reason as Error).message);

  // with a reason it rejects with exactly that value
  const custom = await forever()
    .cancel({ code: 'TIMEOUT' })
    .catch((reason) => reason);

  return [byDefault, custom]; // ['Promise canceled', { code: 'TIMEOUT' }]
}

A cancellation is not a failure

For await and catch a canceled promise is a rejected promise. Read status to know which one it was.

handle.ts
import type { CancelablePromise } from 'easy-cancelable-promise';

export async function load<T>(request: CancelablePromise<T>): Promise<T | null> {
  try {
    return await request;
  } catch (error) {
    // a cancellation is not a failure: the status tells them apart
    if (request.status === 'canceled') return null;

    throw error;
  }
}

No unhandled rejection

The promise you call cancel on never reports an unhandled rejection, even if nothing catches it. A promise chained from it is canceled too, but its rejection follows the normal rules: without a handler it is reported.

unhandled.ts
import { CancelablePromise } from 'easy-cancelable-promise';

const task = new CancelablePromise<void>(() => {});

// no catch needed: canceling a promise never causes an unhandled rejection
task.cancel();

// a promise chained from it is canceled too, but nobody called cancel on that one:
// it rejects like any other promise, so handle the chains you create
task.then(() => console.log('never runs')).catch(() => {});

Remove a listener

Inside the callback, onCancel returns the function that removes the listener. Outside, pass a signal.

unsubscribe.ts
import { CancelablePromise, CancelableAbortController } from 'easy-cancelable-promise';

export function unsubscribe() {
  const calls: string[] = [];

  const download = new CancelablePromise<void>((_resolve, _reject, { onCancel }) => {
    // inside the promise, onCancel returns the function that removes the listener
    const removeFirst = onCancel(() => calls.push('abort the request'));

    // the request is over: there is nothing to abort any more
    removeFirst();

    onCancel(() => calls.push('delete the temporary file'));
  });

  // outside, a listener is removed with a signal
  const controller = new CancelableAbortController();

  download.onCancel(() => calls.push('removed before the cancellation'), { signal: controller.signal });

  controller.abort();
  download.cancel();

  return calls; // ['delete the temporary file']
}

This is how a promise changes its cleanup as the work advances: abort the request while it downloads, delete the temporary file while it saves. See Abort signals for the controller.

Cancel from inside

The utilities include cancel, so a promise can give up by itself.

self-cancel.ts
import { CancelablePromise } from 'easy-cancelable-promise';

export const withTimeout = (url: string, milliseconds: number) =>
  new CancelablePromise<Response>((resolve, reject, { cancel, onCancel }) => {
    const controller = new AbortController();

    // the promise cancels itself when the request takes too long
    const timer = setTimeout(() => cancel('Request timeout'), milliseconds);

    onCancel(() => {
      clearTimeout(timer);
      controller.abort();
    });

    fetch(url, { signal: controller.signal })
      .then(resolve, reject)
      .finally(() => clearTimeout(timer));
  });

When a listener throws

A listener that throws does not stop the cancellation. The other listeners are still called and the promise is canceled. cancel then throws the first error, so it is not lost.

listener-throws.ts
import { CancelablePromise } from 'easy-cancelable-promise';

export function listenerThrows() {
  const calls: string[] = [];

  const task = new CancelablePromise<void>(() => {})
    .onCancel(() => {
      throw new Error('This listener failed');
    })
    .onCancel(() => calls.push('still called'));

  try {
    task.cancel();
  } catch (error) {
    // the first error is thrown by cancel, after the promise is canceled
    calls.push((error as Error).message);
  }

  return [task.status, ...calls]; // ['canceled', 'still called', 'This listener failed']
}

Next

Documentation
↑↓ Navigate ↵ Open Esc Closeeasy-cancelable-promise docs
Get started

Promises you can actually control.