Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/concepts/actions/actions-life-cycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,50 @@ async getNovels(ctx: StateContext<BooksStateModel>) {

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<Novel[]>) {
// 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<BooksStateModel>, 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`.
126 changes: 126 additions & 0 deletions docs/concepts/actions/cancellation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ZooStateModel>({
defaults: {
animals: []
}
})
@Injectable()
export class ZooState {
constructor(private animalService: AnimalService) {}

@Action(FetchAnimals, { cancelUncompleted: true })
async fetchAnimals(ctx: StateContext<ZooStateModel>) {
// 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<ZooStateModel>({
defaults: {
animals: [],
loading: false
}
})
@Injectable()
export class ZooState {
@Action(SearchAnimals, { cancelUncompleted: true })
async searchAnimals(ctx: StateContext<ZooStateModel>, 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<ZooStateModel>({
defaults: {
animals: []
}
})
@Injectable()
export class ZooState {
constructor(private animalService: AnimalService) {}

@Action(FeedAnimals, { cancelUncompleted: true })
feedAnimals(ctx: StateContext<ZooStateModel>, 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.
Expand Down
56 changes: 55 additions & 1 deletion docs/concepts/state/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,15 @@ export class ZooState {
}
```

The `feedAnimals` function has one argument called `ctx` with a type of `StateContext<ZooStateModel>`. 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<ZooStateModel>`. 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

Expand Down Expand Up @@ -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<ZooStateModel>({
name: 'zoo',
defaults: {
feedAnimals: []
}
})
@Injectable()
export class ZooState {
constructor(private animalService: AnimalService) {}

@Action(FeedAnimals, { cancelUncompleted: true })
async feedAnimals(ctx: StateContext<ZooStateModel>, 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.
Expand Down
71 changes: 71 additions & 0 deletions docs/recipes/debouncing-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `
<app-news-search [lastSearchedTitle]="lastSearchedTitle()" (search)="search($event)" />
<app-news [news]="news()" />
`,
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<NewsStateModel>({
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<NewsStateModel>, { 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.
Loading