skip to content

In an NGXS hotel booking app, a room-search action fires on every keystroke and older results overwrite newer ones; why, and how do cancelUncompleted and ctx.abortSignal fix it?

level: seniorimportance: should knowfreq 24%

answer

  1. handlers may return Observable or Promise
  2. same-type async actions overlap
  3. last response to arrive wins
  4. an @Action decorator option
  5. AbortSignal on the context, since 21

basics

~20 s

NGXS runs async action handlers in parallel, so a slow earlier search can finish last and overwrite newer results. @Action(SearchRooms, { cancelUncompleted: true }) cancels the previous run on each new dispatch; ctx.abortSignal lets async/await code stop too.

solid answer

~50 s

An NGXS `@Action` handler can return an Observable or a Promise; NGXS subscribes to it and the action completes only when it finishes. Instances of the same action are handled in parallel by default, so two overlapping searches both write, and whichever response arrives last wins, even if it belongs to the older query. `@Action(SearchRooms, { cancelUncompleted: true })` tells NGXS to cancel the previous uncompleted run when a new `SearchRooms` is dispatched: it unsubscribes from the returned Observable, which aborts an `HttpClient` request, and marks the old action `CANCELED`. Since NGXS 21 the `StateContext` also exposes `abortSignal`, which is aborted at that moment; with `async`/`await` you pass it to `fetch` or check `ctx.abortSignal.aborted` after each `await`. NGXS also ignores `setState` and `patchState` calls on a cancelled context, but other side effects still run unless you stop them.

code

ts · 26 lines
ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Action, State, StateContext } from '@ngxs/store';
import { tap } from 'rxjs';

export interface Room { id: string; number: string; }
export interface RoomSearchStateModel { results: Room[]; loading: boolean; }

export class SearchRooms {
  static readonly type = '[Room Search] Search Rooms';
  constructor(public query: string) {}
}

@State<RoomSearchStateModel>({ name: 'roomSearch', defaults: { results: [], loading: false } })
@Injectable()
export class RoomSearchState {
  private http = inject(HttpClient);

  @Action(SearchRooms, { cancelUncompleted: true })
  search(ctx: StateContext<RoomSearchStateModel>, { query }: SearchRooms) {
    ctx.patchState({ loading: true });
    return this.http.get<Room[]>('/api/rooms', { params: { q: query } }).pipe(
      tap(results => ctx.patchState({ results, loading: false }))
    );
  }
}

go deeper

for a junior

Recall that NGXS handlers can return an Observable or Promise, and that cancelUncompleted is an @Action option that drops the previous run of the same action.

for a middle

Explain why overlapping async actions race, what NGXS does on cancellation, and how the four lifecycle statuses map to the ofAction operators.

for a senior

Show you would wire ctx.abortSignal into async handlers, avoid cancelling writes that must land, and not trust an awaited dispatch to mean the results belong to your query.

for a principal

Define which action kinds may cancel their predecessors and how failures surface app-wide, so race handling is a rule in the codebase rather than a per-handler guess.

## Why the stale results win In NGXS an `@Action` handler may do asynchronous work itself: it can return an **Observable** or a **Promise**. NGXS subscribes to what you return, and the action counts as complete only when that work finishes. That is how the `dispatch` result knows when to complete. By default NGXS handles **every dispatched instance independently and in parallel**. For a search box that dispatches `SearchRooms` on each keystroke: 1. `SearchRooms('sea')` starts a slow request. 2. `SearchRooms('sea view')` starts a fast request and writes its results. 3. The slow `'sea'` response arrives afterwards and overwrites them. Nothing is wrong with each handler in isolation; the bug is that stale work is still allowed to finish. ## cancelUncompleted: cancel the previous run `cancelUncompleted` is an option on the `@Action` decorator: ```ts @Action(SearchRooms, { cancelUncompleted: true }) search(ctx: StateContext<RoomSearchStateModel>, { query }: SearchRooms) { ctx.patchState({ loading: true }); return this.http.get<Room[]>('/api/rooms', { params: { q: query } }).pipe( tap(results => ctx.patchState({ results, loading: false })) ); } ``` When a new `SearchRooms` is dispatched while an earlier one is still running, NGXS: - **unsubscribes** from the earlier handler's returned Observable, so an `HttpClient` request is aborted and its `tap` never runs; - **aborts** the earlier context's `abortSignal`; - marks the earlier action **`CANCELED`** in the actions stream; - completes that earlier `dispatch` result **without emitting**. The behaviour resembles `switchMap`, which the NGXS docs use as the comparison. It applies only to handlers that return an Observable or Promise; a synchronous handler has nothing to cancel. ## abortSignal for async/await handlers A Promise cannot be unsubscribed from. If the handler is `async`, cancellation stops NGXS from waiting, but the function keeps running. Since NGXS 21, `StateContext` exposes an `abortSignal` that NGXS aborts at the moment of cancellation. Use it in two ways: - pass it to APIs that accept an `AbortSignal`, such as `fetch(url, { signal: ctx.abortSignal })`, so the network request itself stops; - check `ctx.abortSignal.aborted` after each `await` and return early. Once a handler's returned work has completed or been cancelled, NGXS turns that context's `setState` and `patchState` into no-ops, with a console warning in development. So a late write is already ignored. But `ctx.dispatch`, logging, analytics calls and further `await`s still run, which is why the explicit check is still worth writing. ## Observing the outcome Every action moves through a small lifecycle: `DISPATCHED`, then `SUCCESSFUL`, `ERRORED` or `CANCELED`. Two ways to react: | Tool | What it gives you | |---|---| | The `dispatch` result | Emits then completes on success, errors if the handler throws, completes silently when cancelled | | `Actions` stream with `ofActionSuccessful`, `ofActionErrored`, `ofActionCanceled`, `ofActionCompleted` | App-wide listeners, for example a toast on a failed booking; `ofActionCompleted` reports `successful`, `canceled` and `error` together | In NGXS 22 the standalone `dispatch()` function returns a value that is both an Observable and awaitable. A trap follows from the table: `await searchRooms('sea')` **resolves normally** when that search is cancelled, because the underlying stream just completes. Code after the `await` must not assume the results in state belong to its query. ## Errors are a different path Cancellation and failure are easy to conflate, and the lifecycle keeps them apart: - A handler that **throws**, or whose Observable or Promise **errors**, ends as `ERRORED`, and the `dispatch` result errors with the same error. - NGXS first lets the `dispatch` caller handle that error: a `subscribe` error callback, an RxJS error operator, or `try`/`catch` around an awaited result. Only an error nobody handled reaches NGXS's `NgxsUnhandledErrorHandler`. - The NGXS docs recommend handling expected errors inside the `@Action` handler itself, by recording the failure in state, for example an `error` field on the search slice, so a selector can show it to the guest. - A cancelled search is **not** an error. It never reaches the error handler, so a component that only listens for errors will not notice that its request was superseded. ## Choosing the right tool - Typeahead or filter changes where only the newest request matters: `cancelUncompleted: true`. - Writes that must all land, such as confirming two different reservations: leave the default, since cancelling would drop a real booking. - Cancelling on a **different** action, such as leaving the page: the docs show piping the returned Observable through `takeUntil(this.actions$.pipe(ofAction(LeaveSearch)))`. - `async` handlers with `cancelUncompleted`: always wire `ctx.abortSignal`.

  • In NGXS, how would you show a toast whenever a booking action fails, without touching every component that dispatches it?
    Inject `Actions` in a root service and subscribe to `actions$.pipe(ofActionErrored(CreateBooking))`. `ofActionErrored` emits an `ActionCompletion` whose `result.error` holds the thrown error, so the service can map it to a message. Components keep dispatching as before, and the listener lives in one place with a clear owner.
  • Why not put cancelUncompleted on every async NGXS action by default?
    Because cancelling drops work, not just results. For a search, only the newest query matters, so dropping older ones is correct. For writes such as confirming two different reservations, a second dispatch would cancel the first confirmation mid-flight. Use it where later instances genuinely supersede earlier ones.

saying these in an interview costs you the question

  • NGXS queues async actions of the same type and runs them one after another.
  • cancelUncompleted waits for an Observable handler's request to finish and only discards its result.
  • A cancelled action ends with ERRORED status, so ofActionErrored sees it.
  • Awaiting dispatch() rejects when the action is cancelled by a newer one.
  • With async/await, cancelUncompleted also stops the rest of the function from running.