Skip to content
easy-cancelable-promise
Interactive example · Groups

Six tasks, one cancel

Six tasks, two at a time, in a queue written with native promises and in groupAsCancelablePromise. Press Stop and see which tasks still start.

Try it hereLive example
Application preview

Loading the example…

GroupDemo.tsx
import { useEffect, useRef, useState } from 'react';
import { groupAsCancelablePromise, type CancelablePromise } from 'easy-cancelable-promise';
import '../shared/demo.css';
import { createLog } from '../shared/log';
import { count, milliseconds } from '../shared/format';
import { DEFAULT_DURATION, DURATIONS, type Duration } from '../shared/durations';
import { searchWithCancelablePromise, searchWithNativePromise } from '../shared/search';
import { useElapsed } from '../shared/useElapsed';
import { runInBatches } from './batches';

type TaskStatus = 'queued' | 'running' | 'done' | 'canceled' | 'never started';

interface Task {
  status: TaskStatus;
  step: number;
  /** The task started after Stop was pressed. */
  startedAfterStop: boolean;
}

interface Side {
  status: 'idle' | 'running' | 'still running' | 'finished' | 'resolved' | 'canceled';
  tasks: Task[];
  /** Steps that ran after Stop was pressed. */
  afterStop: number;
  total: number | null;
}

const log = createLog();

export const TASKS = 6;
export const STEPS_PER_TASK = 10;
export const MAX_CONCURRENT = 2;

const idleTasks = (): Task[] => Array.from({ length: TASKS }, () => ({ status: 'queued', step: 0, startedAfterStop: false }));

const idle = (): Side => ({ status: 'idle', tasks: idleTasks(), afterStop: 0, total: null });

const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);

export function GroupDemo() {
  const [duration, setDuration] = useState<Duration>(DEFAULT_DURATION);
  const [native, setNative] = useState<Side>(idle);
  const [cancelable, setCancelable] = useState<Side>(idle);
  const [running, setRunning] = useState(false);
  const { elapsed, start: startClock, stop: stopClock } = useElapsed();

  const group = useRef<CancelablePromise<number[]> | null>(null);
  const stoppedAt = useRef<number | null>(null);
  const pending = useRef(0);

  useEffect(
    () => () => {
      group.current?.cancel('The example was closed');
    },
    [],
  );

  const settle = () => {
    pending.current -= 1;

    if (pending.current > 0) return;

    setRunning(false);
    stopClock();
  };

  const start = () => {
    // the tasks run in batches of MAX_CONCURRENT, one batch after the other
    const pause = (duration * 1000) / ((TASKS / MAX_CONCURRENT) * STEPS_PER_TASK);

    stoppedAt.current = null;
    pending.current = 2;

    setRunning(true);
    setNative({ ...idle(), status: 'running' });
    setCancelable({ ...idle(), status: 'running' });
    startClock();
    log.write(`started · ${TASKS} tasks, ${MAX_CONCURRENT} at a time`);

    // `afterStop` is decided when the step or the task runs, not when React applies the update:
    // what ran right before Stop must not be counted as something that ran after it.
    const update = (set: typeof setNative, index: number, changes: Partial<Task>, stepRan = false) => {
      const afterStop = stepRan && stoppedAt.current !== null;

      set((current) => ({
        ...current,
        afterStop: current.afterStop + (afterStop ? 1 : 0),
        tasks: current.tasks.map((task, position) => (position === index ? { ...task, ...changes } : task)),
      }));
    };

    const options = (set: typeof setNative, index: number) => ({
      from: index * STEPS_PER_TASK,
      steps: STEPS_PER_TASK,
      pause,
      onStep: ({ step }: { step: number }) => update(set, index, { step, status: step === STEPS_PER_TASK ? 'done' : 'running' }, true),
    });

    // native promises in a queue written by hand: Stop cannot reach the running tasks, nor the queue
    runInBatches(
      Array.from({ length: TASKS }, (_, index) => () => {
        update(setNative, index, { status: 'running', startedAfterStop: stoppedAt.current !== null });

        return searchWithNativePromise(options(setNative, index));
      }),
      MAX_CONCURRENT,
    ).then((results) => {
      setNative((current) => ({ ...current, total: sum(results), status: stoppedAt.current === null ? 'resolved' : 'finished' }));

      if (stoppedAt.current !== null) {
        log.write(`Promise queue · finished anyway, ${milliseconds(performance.now() - stoppedAt.current)} after Stop`);
      }

      settle();
    });

    // one cancelable group: canceling it cancels the running tasks and never starts the queued ones
    group.current = groupAsCancelablePromise<number[]>(
      Array.from({ length: TASKS }, (_, index) => () => {
        update(setCancelable, index, { status: 'running' });

        return searchWithCancelablePromise(options(setCancelable, index)).onCancel(() =>
          update(setCancelable, index, { status: 'canceled' }),
        );
      }),
      { maxConcurrent: MAX_CONCURRENT },
    );

    group.current!.then(
      (results) => {
        setCancelable((current) => ({ ...current, total: sum(results), status: 'resolved' }));
        log.write(`group · resolved · ${count(sum(results))} primes`);
        settle();
      },
      (reason) => {
        setCancelable((current) => ({
          ...current,
          status: 'canceled',
          tasks: current.tasks.map((task) => (task.status === 'queued' ? { ...task, status: 'never started' } : task)),
        }));
        log.write(`group · canceled · reason: "${String(reason)}"`);
        settle();
      },
    );
  };

  const stop = () => {
    if (stoppedAt.current !== null) return;

    stoppedAt.current = performance.now();

    setNative((current) => (current.status === 'running' ? { ...current, status: 'still running' } : current));
    group.current?.cancel('Stopped by user');
  };

  const stopped = stoppedAt.current !== null;
  const amount = (side: Side, status: TaskStatus) => side.tasks.filter((task) => task.status === status).length;

  const card = (name: string, title: string, side: Side, good: boolean) => {
    const tone = !stopped || side.status === 'idle' ? '' : good ? 'demo-stat--good' : 'demo-stat--bad';
    const startedAfterStop = side.tasks.filter((task) => task.startedAfterStop).length;

    return (
      <section className="demo-card" aria-label={title}>
        <span>{title}</span>
        <ul className="demo-lanes">
          {side.tasks.map((task, index) => (
            <li className={`demo-lane ${task.status === 'done' ? 'demo-lane--done' : ''} ${task.status === 'canceled' || task.status === 'never started' ? 'demo-lane--stopped' : ''}`} key={index}>
              <span>task {index + 1}</span>
              <div className="demo-progress" aria-hidden="true">
                <span style={{ width: `${(task.step / STEPS_PER_TASK) * 100}%` }} />
              </div>
              <span data-testid={`${name}-task-${index + 1}`}>{task.status}</span>
            </li>
          ))}
        </ul>
        <dl className="demo-stats demo-stats--fixed">
          <div className={`demo-stat demo-stat--wide ${side.status === 'canceled' ? 'demo-stat--good' : side.status === 'still running' || side.status === 'finished' ? 'demo-stat--bad' : ''}`}>
            <dt>Status</dt>
            <dd data-testid={`${name}-status`}>{side.status}</dd>
          </div>
          <div className="demo-stat">
            <dt>Tasks done</dt>
            <dd data-testid={`${name}-done`}>
              {amount(side, 'done')} / {TASKS}
            </dd>
          </div>
          <div className="demo-stat">
            <dt>Primes found</dt>
            <dd data-testid={`${name}-total`}>{side.total === null ? '—' : count(side.total)}</dd>
          </div>
          <div className={`demo-stat ${tone}`}>
            <dt>Tasks started after Stop</dt>
            <dd data-testid={`${name}-started-after-stop`}>{stopped ? startedAfterStop : '—'}</dd>
          </div>
          <div className={`demo-stat ${tone}`}>
            <dt>Steps after Stop</dt>
            <dd data-testid={`${name}-after-stop`}>{stopped ? side.afterStop : '—'}</dd>
          </div>
        </dl>
      </section>
    );
  };

  return (
    <div className="demo">
      <section className="demo-card" aria-label="Controls">
        <fieldset className="segmented">
          <legend>
            {TASKS} tasks, {MAX_CONCURRENT} at a time
          </legend>
          {DURATIONS.map((value) => (
            <label key={value}>
              <input type="radio" name="group-duration" checked={duration === value} disabled={running} onChange={() => setDuration(value)} />
              <span>{value} s</span>
            </label>
          ))}
        </fieldset>
        <div className="demo-actions">
          <button type="button" className="demo-button demo-button--primary" onClick={start} disabled={running}>
            {running ? 'Running…' : 'Start both'}
          </button>
          <button type="button" className="demo-button" onClick={stop} disabled={!running || stopped}>
            Stop both
          </button>
          <span className="demo-clock" data-testid="group-elapsed">
            {milliseconds(elapsed)}
          </span>
        </div>
        <p className="demo-caption">Press Stop while the first tasks run. One call cancels the whole group, including the tasks that did not start yet.</p>
      </section>

      <div className="demo-results">
        <div className="demo-results-grid">
          {card('native', 'Promise queue · runs to the end', native, false)}
          {card('cancelable', 'groupAsCancelablePromise · one cancel', cancelable, true)}
        </div>
      </div>
    </div>
  );
}

/** Nothing outside the component to restore: the workbench remounts the demo. */
export const resetGroupDemo = () => {};

export const watchGroupDemo = log.watch;
Ready. Run the example to begin.Reset returns this example to its initial state.
Take it with you

A group is one promise. Canceling it cancels the tasks that run and never starts the ones that wait.

01

Press Start both, then Stop both while the first tasks run. The cancelable group marks its running tasks as canceled and the rest as never started.

02

Look at Tasks started after Stop on the native side. The queue keeps starting tasks, because nothing can reach it.

03

Run it to the end. Both find the same primes, and the group returns them in the order of the tasks.

Read the full guide

The code

This is the queue written by hand with native promises: batches of two, one batch after the other. It works, and it cannot be stopped.

batches.ts
/**
 * The same queue written by hand with native promises: batches of `size`, one batch after the other.
 * Nothing here can be stopped. After the caller walks away the running tasks finish, and the tasks
 * that are still in the queue start anyway.
 */
export async function runInBatches<T>(tasks: (() => Promise<T>)[], size: number): Promise<T[]> {
  const results: T[] = [];

  for (let index = 0; index < tasks.length; index += size) {
    const batch = tasks.slice(index, index + size).map((task) => task());

    results.push(...(await Promise.all(batch)));
  }

  return results;
}

groupAsCancelablePromise does the same with one call, and returns a promise that can be canceled.

GroupDemo.tsx
import { useEffect, useRef, useState } from 'react';
import { groupAsCancelablePromise, type CancelablePromise } from 'easy-cancelable-promise';
import '../shared/demo.css';
import { createLog } from '../shared/log';
import { count, milliseconds } from '../shared/format';
import { DEFAULT_DURATION, DURATIONS, type Duration } from '../shared/durations';
import { searchWithCancelablePromise, searchWithNativePromise } from '../shared/search';
import { useElapsed } from '../shared/useElapsed';
import { runInBatches } from './batches';

type TaskStatus = 'queued' | 'running' | 'done' | 'canceled' | 'never started';

interface Task {
  status: TaskStatus;
  step: number;
  /** The task started after Stop was pressed. */
  startedAfterStop: boolean;
}

interface Side {
  status: 'idle' | 'running' | 'still running' | 'finished' | 'resolved' | 'canceled';
  tasks: Task[];
  /** Steps that ran after Stop was pressed. */
  afterStop: number;
  total: number | null;
}

const log = createLog();

export const TASKS = 6;
export const STEPS_PER_TASK = 10;
export const MAX_CONCURRENT = 2;

const idleTasks = (): Task[] => Array.from({ length: TASKS }, () => ({ status: 'queued', step: 0, startedAfterStop: false }));

const idle = (): Side => ({ status: 'idle', tasks: idleTasks(), afterStop: 0, total: null });

const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);

export function GroupDemo() {
  const [duration, setDuration] = useState<Duration>(DEFAULT_DURATION);
  const [native, setNative] = useState<Side>(idle);
  const [cancelable, setCancelable] = useState<Side>(idle);
  const [running, setRunning] = useState(false);
  const { elapsed, start: startClock, stop: stopClock } = useElapsed();

  const group = useRef<CancelablePromise<number[]> | null>(null);
  const stoppedAt = useRef<number | null>(null);
  const pending = useRef(0);

  useEffect(
    () => () => {
      group.current?.cancel('The example was closed');
    },
    [],
  );

  const settle = () => {
    pending.current -= 1;

    if (pending.current > 0) return;

    setRunning(false);
    stopClock();
  };

  const start = () => {
    // the tasks run in batches of MAX_CONCURRENT, one batch after the other
    const pause = (duration * 1000) / ((TASKS / MAX_CONCURRENT) * STEPS_PER_TASK);

    stoppedAt.current = null;
    pending.current = 2;

    setRunning(true);
    setNative({ ...idle(), status: 'running' });
    setCancelable({ ...idle(), status: 'running' });
    startClock();
    log.write(`started · ${TASKS} tasks, ${MAX_CONCURRENT} at a time`);

    // `afterStop` is decided when the step or the task runs, not when React applies the update:
    // what ran right before Stop must not be counted as something that ran after it.
    const update = (set: typeof setNative, index: number, changes: Partial<Task>, stepRan = false) => {
      const afterStop = stepRan && stoppedAt.current !== null;

      set((current) => ({
        ...current,
        afterStop: current.afterStop + (afterStop ? 1 : 0),
        tasks: current.tasks.map((task, position) => (position === index ? { ...task, ...changes } : task)),
      }));
    };

    const options = (set: typeof setNative, index: number) => ({
      from: index * STEPS_PER_TASK,
      steps: STEPS_PER_TASK,
      pause,
      onStep: ({ step }: { step: number }) => update(set, index, { step, status: step === STEPS_PER_TASK ? 'done' : 'running' }, true),
    });

    // native promises in a queue written by hand: Stop cannot reach the running tasks, nor the queue
    runInBatches(
      Array.from({ length: TASKS }, (_, index) => () => {
        update(setNative, index, { status: 'running', startedAfterStop: stoppedAt.current !== null });

        return searchWithNativePromise(options(setNative, index));
      }),
      MAX_CONCURRENT,
    ).then((results) => {
      setNative((current) => ({ ...current, total: sum(results), status: stoppedAt.current === null ? 'resolved' : 'finished' }));

      if (stoppedAt.current !== null) {
        log.write(`Promise queue · finished anyway, ${milliseconds(performance.now() - stoppedAt.current)} after Stop`);
      }

      settle();
    });

    // one cancelable group: canceling it cancels the running tasks and never starts the queued ones
    group.current = groupAsCancelablePromise<number[]>(
      Array.from({ length: TASKS }, (_, index) => () => {
        update(setCancelable, index, { status: 'running' });

        return searchWithCancelablePromise(options(setCancelable, index)).onCancel(() =>
          update(setCancelable, index, { status: 'canceled' }),
        );
      }),
      { maxConcurrent: MAX_CONCURRENT },
    );

    group.current!.then(
      (results) => {
        setCancelable((current) => ({ ...current, total: sum(results), status: 'resolved' }));
        log.write(`group · resolved · ${count(sum(results))} primes`);
        settle();
      },
      (reason) => {
        setCancelable((current) => ({
          ...current,
          status: 'canceled',
          tasks: current.tasks.map((task) => (task.status === 'queued' ? { ...task, status: 'never started' } : task)),
        }));
        log.write(`group · canceled · reason: "${String(reason)}"`);
        settle();
      },
    );
  };

  const stop = () => {
    if (stoppedAt.current !== null) return;

    stoppedAt.current = performance.now();

    setNative((current) => (current.status === 'running' ? { ...current, status: 'still running' } : current));
    group.current?.cancel('Stopped by user');
  };

  const stopped = stoppedAt.current !== null;
  const amount = (side: Side, status: TaskStatus) => side.tasks.filter((task) => task.status === status).length;

  const card = (name: string, title: string, side: Side, good: boolean) => {
    const tone = !stopped || side.status === 'idle' ? '' : good ? 'demo-stat--good' : 'demo-stat--bad';
    const startedAfterStop = side.tasks.filter((task) => task.startedAfterStop).length;

    return (
      <section className="demo-card" aria-label={title}>
        <span>{title}</span>
        <ul className="demo-lanes">
          {side.tasks.map((task, index) => (
            <li className={`demo-lane ${task.status === 'done' ? 'demo-lane--done' : ''} ${task.status === 'canceled' || task.status === 'never started' ? 'demo-lane--stopped' : ''}`} key={index}>
              <span>task {index + 1}</span>
              <div className="demo-progress" aria-hidden="true">
                <span style={{ width: `${(task.step / STEPS_PER_TASK) * 100}%` }} />
              </div>
              <span data-testid={`${name}-task-${index + 1}`}>{task.status}</span>
            </li>
          ))}
        </ul>
        <dl className="demo-stats demo-stats--fixed">
          <div className={`demo-stat demo-stat--wide ${side.status === 'canceled' ? 'demo-stat--good' : side.status === 'still running' || side.status === 'finished' ? 'demo-stat--bad' : ''}`}>
            <dt>Status</dt>
            <dd data-testid={`${name}-status`}>{side.status}</dd>
          </div>
          <div className="demo-stat">
            <dt>Tasks done</dt>
            <dd data-testid={`${name}-done`}>
              {amount(side, 'done')} / {TASKS}
            </dd>
          </div>
          <div className="demo-stat">
            <dt>Primes found</dt>
            <dd data-testid={`${name}-total`}>{side.total === null ? '—' : count(side.total)}</dd>
          </div>
          <div className={`demo-stat ${tone}`}>
            <dt>Tasks started after Stop</dt>
            <dd data-testid={`${name}-started-after-stop`}>{stopped ? startedAfterStop : '—'}</dd>
          </div>
          <div className={`demo-stat ${tone}`}>
            <dt>Steps after Stop</dt>
            <dd data-testid={`${name}-after-stop`}>{stopped ? side.afterStop : '—'}</dd>
          </div>
        </dl>
      </section>
    );
  };

  return (
    <div className="demo">
      <section className="demo-card" aria-label="Controls">
        <fieldset className="segmented">
          <legend>
            {TASKS} tasks, {MAX_CONCURRENT} at a time
          </legend>
          {DURATIONS.map((value) => (
            <label key={value}>
              <input type="radio" name="group-duration" checked={duration === value} disabled={running} onChange={() => setDuration(value)} />
              <span>{value} s</span>
            </label>
          ))}
        </fieldset>
        <div className="demo-actions">
          <button type="button" className="demo-button demo-button--primary" onClick={start} disabled={running}>
            {running ? 'Running…' : 'Start both'}
          </button>
          <button type="button" className="demo-button" onClick={stop} disabled={!running || stopped}>
            Stop both
          </button>
          <span className="demo-clock" data-testid="group-elapsed">
            {milliseconds(elapsed)}
          </span>
        </div>
        <p className="demo-caption">Press Stop while the first tasks run. One call cancels the whole group, including the tasks that did not start yet.</p>
      </section>

      <div className="demo-results">
        <div className="demo-results-grid">
          {card('native', 'Promise queue · runs to the end', native, false)}
          {card('cancelable', 'groupAsCancelablePromise · one cancel', cancelable, true)}
        </div>
      </div>
    </div>
  );
}

/** Nothing outside the component to restore: the workbench remounts the demo. */
export const resetGroupDemo = () => {};

export const watchGroupDemo = log.watch;

Related documentation

Keep exploring

Another piece of the model.

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

Promises you can actually control.