From 2647f6a977aa1c7c1b888fd62628e808ec534c3e Mon Sep 17 00:00:00 2001 From: markwhitfeld Date: Wed, 17 Dec 2025 22:43:13 +0200 Subject: [PATCH] docs: add relevant sections regarding AbortSignal usage --- docs/concepts/actions/actions-life-cycle.md | 44 +++++++ docs/concepts/actions/cancellation.md | 126 ++++++++++++++++++++ docs/concepts/state/README.md | 56 ++++++++- docs/recipes/debouncing-actions.md | 71 +++++++++++ 4 files changed, 296 insertions(+), 1 deletion(-) diff --git a/docs/concepts/actions/actions-life-cycle.md b/docs/concepts/actions/actions-life-cycle.md index 61f482a63..258b54a8f 100644 --- a/docs/concepts/actions/actions-life-cycle.md +++ b/docs/concepts/actions/actions-life-cycle.md @@ -317,6 +317,50 @@ async getNovels(ctx: StateContext) { Note: leaving out the final `await` keyword here would cause this to be "fire and forget" again. +## Handling Cancellation with AbortSignal + +When using `cancelUncompleted`, NGXS provides an `abortSignal` property on the `StateContext` (available in v21+) that allows you to detect and respond to action cancellation. This is especially useful when working with async/await: + +```ts +@Action(GetNovels, { cancelUncompleted: true }) +async getNovels(ctx: StateContext) { + // Perform async work + const novels = await firstValueFrom(this.booksService.getNovels()); + + // Check if action was canceled before updating state + if (ctx.abortSignal.aborted) { + return; // Exit gracefully without updating state + } + + ctx.setState(novels); +} +``` + +The `abortSignal` can also be passed directly to the Fetch API: + +```ts +@Action(SearchBooks, { cancelUncompleted: true }) +async searchBooks(ctx: StateContext, action: SearchBooks) { + try { + const response = await fetch(`/api/books?q=${action.query}`, { + signal: ctx.abortSignal // Automatically cancels the request + }); + + const books = await response.json(); + ctx.patchState({ books }); + } catch (error) { + if (error.name === 'AbortError') { + return; // Gracefully handle cancellation + } + throw error; + } +} +``` + +When you return an Observable from an action handler, NGXS automatically unsubscribes when the action is canceled, so you don't need to manually check the `abortSignal`. + +For more details on action cancellation, see the [Cancellation guide](cancellation.md). + ## Summary In summary - any dispatched action starts with the status `DISPATCHED`. Next, NGXS looks for handlers that listen to this action, if there are any — NGXS invokes them and processes the return value and errors. If the handler has done some work and has not thrown an error, the status of the action changes to `SUCCESSFUL`. If something went wrong while processing the action (for example, if the server returned an error) then the status of the action changes to `ERRORED`. And if an action handler is marked as `cancelUncompleted` and a new action has arrived before the old one was processed then NGXS interrupts the processing of the first action and sets the action status to `CANCELED`. diff --git a/docs/concepts/actions/cancellation.md b/docs/concepts/actions/cancellation.md index 854f96996..6a03e0448 100644 --- a/docs/concepts/actions/cancellation.md +++ b/docs/concepts/actions/cancellation.md @@ -29,6 +29,132 @@ export class ZooState { } ``` +## Using AbortSignal + +Starting from NGXS v21, the `StateContext` includes an `abortSignal` property that provides a standardized way to handle cancellation of asynchronous operations. This is particularly useful when working with `cancelUncompleted` actions. + +### Why AbortSignal? + +The `AbortSignal` provides a standard browser API to detect and respond to cancellations. When an action marked with `cancelUncompleted: true` is canceled (because a new instance was dispatched), the `abortSignal` will be aborted, allowing you to: + +- Check cancellation status in async/await code +- Pass the signal to fetch requests for automatic cancellation +- Clean up resources gracefully +- Avoid unnecessary state updates + +### With Async/Await + +When using async/await, check `ctx.abortSignal.aborted` after await points to handle cancellation: + +```ts +import { Injectable } from '@angular/core'; +import { State, Action, StateContext } from '@ngxs/store'; + +export class FetchAnimals { + static readonly type = '[Zoo] Fetch Animals'; +} + +@State({ + defaults: { + animals: [] + } +}) +@Injectable() +export class ZooState { + constructor(private animalService: AnimalService) {} + + @Action(FetchAnimals, { cancelUncompleted: true }) + async fetchAnimals(ctx: StateContext) { + // Perform async work + const animals = await this.animalService.getAnimals(); + + // Check if canceled before updating state + if (ctx.abortSignal.aborted) { + console.log('Action was canceled, skipping state update'); + return; + } + + ctx.setState({ animals }); + } +} +``` + +### With Fetch API + +The `AbortSignal` works seamlessly with the Fetch API: + +```ts +import { Injectable } from '@angular/core'; +import { State, Action, StateContext } from '@ngxs/store'; + +export class SearchAnimals { + static readonly type = '[Zoo] Search Animals'; + constructor(public query: string) {} +} + +@State({ + defaults: { + animals: [], + loading: false + } +}) +@Injectable() +export class ZooState { + @Action(SearchAnimals, { cancelUncompleted: true }) + async searchAnimals(ctx: StateContext, action: SearchAnimals) { + ctx.patchState({ loading: true }); + + try { + // Pass the abort signal directly to fetch + const response = await fetch(`/api/animals?q=${action.query}`, { + signal: ctx.abortSignal + }); + + const animals = await response.json(); + ctx.patchState({ animals, loading: false }); + } catch (error) { + // Handle abort gracefully + if (error.name === 'AbortError') { + console.log('Search was canceled'); + return; // Don't update state or rethrow + } + + // Handle other errors + ctx.patchState({ loading: false }); + throw error; + } + } +} +``` + +### With Observables + +When you return an Observable from an action handler, NGXS automatically unsubscribes when the `abortSignal` is aborted. You don't need to manually check the signal: + +```ts +import { Injectable } from '@angular/core'; +import { State, Action, StateContext } from '@ngxs/store'; +import { tap } from 'rxjs'; + +@State({ + defaults: { + animals: [] + } +}) +@Injectable() +export class ZooState { + constructor(private animalService: AnimalService) {} + + @Action(FeedAnimals, { cancelUncompleted: true }) + feedAnimals(ctx: StateContext, action: FeedAnimals) { + // Observable will be automatically unsubscribed if action is canceled + return this.animalService + .get(action.payload) + .pipe(tap(animals => ctx.setState({ animals }))); + } +} +``` + ## Advanced For more advanced cases, we can use normal Rx operators. diff --git a/docs/concepts/state/README.md b/docs/concepts/state/README.md index 054d96de4..f72ebb5fb 100644 --- a/docs/concepts/state/README.md +++ b/docs/concepts/state/README.md @@ -99,7 +99,15 @@ export class ZooState { } ``` -The `feedAnimals` function has one argument called `ctx` with a type of `StateContext`. This context state has a slice pointer and a function exposed to set the state. It's important to note that the `getState()` method will always return the freshest state slice from the global store each time it is accessed. This ensures that when we're performing async operations the state is always fresh. If you want a snapshot, you can always clone the state in the method. +The `feedAnimals` function has one argument called `ctx` with a type of `StateContext`. This context state has a slice pointer and several functions and properties for managing state: + +- `getState()`: Returns the freshest state slice from the global store. When performing async operations the state is always fresh when you call this method. +- `setState()`: Sets the entire state to a new value +- `patchState()`: Patches only the specified properties +- `dispatch()`: Dispatches one or more actions +- `abortSignal`: An `AbortSignal` tied to the action's lifecycle (available in NGXS v21+). This allows you to handle cancellation of async operations, especially useful with `cancelUncompleted` actions. + +It's important to note that the `getState()` method will always return the freshest state slice from the global store each time it is accessed. This ensures that when we're performing async operations the state is always fresh. If you want a snapshot, you can always clone the state in the method. ### Actions with a payload @@ -316,6 +324,52 @@ export class ZooState { } ``` +### Handling Cancellation in Async Actions + +When using `cancelUncompleted` with async/await, you can use the `abortSignal` property to gracefully handle cancellation: + +```ts +import { Injectable } from '@angular/core'; +import { State, Action } from '@ngxs/store'; + +export class FeedAnimals { + static readonly type = '[Zoo] FeedAnimals'; + + constructor(public animalsToFeed: string) {} +} + +export interface ZooStateModel { + feedAnimals: string[]; +} + +@State({ + name: 'zoo', + defaults: { + feedAnimals: [] + } +}) +@Injectable() +export class ZooState { + constructor(private animalService: AnimalService) {} + + @Action(FeedAnimals, { cancelUncompleted: true }) + async feedAnimals(ctx: StateContext, action: FeedAnimals) { + const result = await this.animalService.feed(action.animalsToFeed); + + // Check if action was canceled before updating state + if (ctx.abortSignal.aborted) { + return; // Exit gracefully without updating state + } + + const state = ctx.getState(); + ctx.setState({ + ...state, + feedAnimals: [...state.feedAnimals, result] + }); + } +} +``` + ### Dispatching Actions From Actions If you want your action to dispatch another action, you can use the `dispatch` function that is contained in the state context object. diff --git a/docs/recipes/debouncing-actions.md b/docs/recipes/debouncing-actions.md index e372baf72..2a08d024a 100644 --- a/docs/recipes/debouncing-actions.md +++ b/docs/recipes/debouncing-actions.md @@ -91,3 +91,74 @@ export class NewsState { ``` The above state is pretty simple. As you can see we don't create an action handler for the `SearchNews` but it still will be passed via `Actions` stream and debounced. It all depends on the task in practice but you're already informed about debouncing actions. + +## Alternative Approach: Using cancelUncompleted + +Instead of debouncing in the component, you can use the `cancelUncompleted` option with the `abortSignal` (available in v21+) to automatically cancel previous search requests: + +```ts +@Component({ + selector: 'app-news-portal', + template: ` + + + `, + standalone: true, + imports: [NewsSearchComponent, NewsComponents] +}) +export class NewsPortalComponent { + news = this.store.selectSignal(NewsState.getNews); + lastSearchedTitle = this.store.selectSignal(NewsState.getLastSearchedTitle); + + constructor(private store: Store) {} + + search(title: string): void { + // Dispatch directly - cancellation is handled by the state + this.store.dispatch(new GetNews(title)); + } +} +``` + +```ts +@State({ + name: 'news', + defaults: { + news: [], + lastSearchedTitle: null + } +}) +@Injectable() +export class NewsState { + @Selector() + static getNews(state: NewsStateModel): News[] { + return state.news; + } + + @Selector() + static getLastSearchedTitle(state: NewsStateModel): string | null { + return state.lastSearchedTitle; + } + + constructor(private http: HttpClient) {} + + @Action(GetNews, { cancelUncompleted: true }) + async getNews(ctx: StateContext, { title }: GetNews) { + try { + // Pass abortSignal to automatically cancel previous requests + const response = await fetch(`/api/news?search=${title}`, { + signal: ctx.abortSignal + }); + + const news = await response.json(); + ctx.setState({ news, lastSearchedTitle: title }); + } catch (error) { + if (error.name === 'AbortError') { + return; // Gracefully handle cancellation + } + throw error; + } + } +} +``` + +This approach is simpler as it moves the cancellation logic into the state where it belongs, and automatically cancels in-flight HTTP requests when a new search is dispatched. You can still combine this with debouncing in the component if you want to delay the dispatch itself.