PraxisJS

Concurrency

@praxisjs/concurrent — async concurrency control decorators @Task, @Queue, and @Pool with reactive loading, error, and pending state.

Concurrency

Async concurrency control from @praxisjs/concurrent. Decorate an async method, declare a companion field, and get reactive .loading(), .error(), and .pending() state automatically — scoped per component instance.

npm install @praxisjs/concurrent
pnpm add @praxisjs/concurrent
yarn add @praxisjs/concurrent
bun add @praxisjs/concurrent

The pattern

Every decorator follows the same structure: the first argument is the async method name, and the decorated field becomes the callable task handle with reactive state attached.

import { Task, TaskOf } from '@praxisjs/concurrent'

@Component()
class UserProfile extends StatefulComponent {
  @State() user: User | null = null

  async loadUser(id: number) {
    this.user = await api.getUser(id)
  }

  @Task('loadUser')
  taskLoadUser!: TaskOf<UserProfile, 'loadUser'>
  // taskLoadUser(1)           → call the method
  // taskLoadUser.loading()    → reactive boolean
  // taskLoadUser.error()      → reactive Error | null
  // taskLoadUser.lastResult() → reactive last return value
}

AbortSignal support (opt-in)

Name the first parameter of your async method signal to receive an AbortSignal. The decorator detects the parameter name and injects the signal automatically — callers never pass it.

async loadUser(signal: AbortSignal, id: number) {
  // Pass to fetch, setTimeout wrappers, or any Web API that accepts a signal
  this.user = await fetch(`/api/users/${id}`, { signal }).then(r => r.json())
}

Methods without a signal parameter work exactly as before. The feature is additive and fully backward-compatible.

When a signal is present:

  • @Task — each new invocation aborts the previous signal
  • @Queueclear() also aborts the currently running item's signal
  • @PoolcancelAll() aborts all active signals

AbortError thrown by a cancelled operation is never stored in .error().


@Task(methodName) — concurrent execution

Runs calls concurrently. If multiple calls are in-flight, only the last one updates state — earlier results are discarded. With signal, earlier requests are also actively aborted.

@Component()
class UserProfile extends StatefulComponent {
  @State() user: User | null = null

  async loadUser(signal: AbortSignal, id: number) {
    this.user = await fetch(`/api/users/${id}`, { signal }).then(r => r.json())
  }

  @Task('loadUser')
  taskLoadUser!: TaskOf<UserProfile, 'loadUser'>

  render() {
    return (
      <div>
        {() => this.taskLoadUser.loading() && <Spinner />}
        {() => this.taskLoadUser.error() && (
          <p>Error: {this.taskLoadUser.error()!.message}</p>
        )}
        {() => this.user && <UserCard user={this.user} />}
        <button onClick={() => this.taskLoadUser(1)}>Load user 1</button>
        <button onClick={() => this.taskLoadUser.cancelAll()}>Cancel</button>
      </div>
    )
  }
}

Reactive state: .loading(), .error(), .lastResult(), .cancelAll()

Storybook
Live demo — @Task (with AbortSignal)
Storybook
Live demo — @Task (without signal)

@Queue(methodName) — serial execution

Calls run one at a time. If a call arrives while one is running, it waits its turn. With signal, calling clear() also aborts the currently running call.

import { Queue, QueueOf } from '@praxisjs/concurrent'

@Component()
class DocumentEditor extends StatefulComponent {
  async saveDocument(signal: AbortSignal, data: DocumentData) {
    await fetch('/api/save', { method: 'POST', body: JSON.stringify(data), signal })
  }

  @Queue('saveDocument')
  taskSaveDocument!: QueueOf<DocumentEditor, 'saveDocument'>

  render() {
    return (
      <div>
        <button onClick={() => this.taskSaveDocument(data)}>Save</button>
        {() => this.taskSaveDocument.pending() > 0 && (
          <p>{() => this.taskSaveDocument.pending()} save(s) queued</p>
        )}
      </div>
    )
  }
}

Reactive state: .loading(), .error(), .pending(), .clear()

Clearing the queue

.clear() cancels all queued (not-yet-started) calls and, when signal is declared, aborts the running call too. Each cancelled call's promise rejects with QueueClearedError:

import { QueueClearedError } from '@praxisjs/concurrent'

onUnmount() {
  this.taskSaveDocument.clear()
}

async saveAll() {
  try {
    await this.taskSaveDocument(data)
  } catch (e) {
    if (e instanceof QueueClearedError) return  // expected — ignore it
    throw e
  }
}
Storybook
Live demo — @Queue (with AbortSignal)
Storybook
Live demo — @Queue (without signal)

@Pool(methodName, concurrency?) — limited parallelism

Limits how many calls run simultaneously. Excess calls are queued automatically and run as slots free up. concurrency defaults to 1.

import { Pool, PoolOf } from '@praxisjs/concurrent'

@Component()
class FileUploader extends StatefulComponent {
  async uploadFile(signal: AbortSignal, file: File) {
    await fetch('/upload', { method: 'POST', body: file, signal })
  }

  @Pool('uploadFile', 3)  // max 3 uploads at once
  taskUploadFile!: PoolOf<FileUploader, 'uploadFile'>

  onMount() {
    files.forEach(f => this.taskUploadFile(f))
  }

  render() {
    return (
      <p>
        Uploading: {() => this.taskUploadFile.active()} /
        Queued: {() => this.taskUploadFile.pending()}
        <button onClick={() => this.taskUploadFile.cancelAll()}>Cancel all</button>
      </p>
    )
  }
}

Reactive state: .loading(), .error(), .active(), .pending(), .cancelAll()

.cancelAll() aborts all active signals (when signal is declared) and resolves all pending calls as undefined.

Storybook
Live demo — @Pool (with AbortSignal)
Storybook
Live demo — @Pool (without signal)

Reactive state reference

Property@Task@Queue@PoolDescription
.loading()True while any call is in-flight
.error()Last error, or null (AbortError excluded)
.lastResult()Return value of last successful call
.pending()Calls waiting in the queue
.active()Calls currently running
.cancelAll()Abort in-flight / pending operations
.clear()Cancel all queued calls with QueueClearedError

Type helpers

Use these generics to type the companion field with full intellisense:

HelperUsage
TaskOf<Class, 'method'>Type for a @Task companion field
QueueOf<Class, 'method'>Type for a @Queue companion field
PoolOf<Class, 'method'>Type for a @Pool companion field

When the method declares signal: AbortSignal as its first parameter, the type helper automatically strips it from the public callable signature — callers only see the remaining arguments.

On this page