Skip to content

Commit c739a26

Browse files
authored
feat(store): implement ActionDirector (#2329)
The `ActionDirector` allows you to attach action handlers to a state at any point after initialization and gives you the ability to detach them when no longer needed.
1 parent ae76ef6 commit c739a26

13 files changed

Lines changed: 315 additions & 97 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ $ npm install @ngxs/store@dev
66

77
### To become next patch version
88

9+
- Feature(store): Implement `ActionDirector` [#2329](https://github.com/ngxs/store/pull/2329)
910
- Feature(store): Add `lazyProvider` utility [#2326](https://github.com/ngxs/store/pull/2326)
1011
- Feature(devtools-plugin): Add `serialize` option [#2337](https://github.com/ngxs/store/pull/2337)
1112
- Refactor: Replace `ngOnDestroy` with `DestroyRef` [#2289](https://github.com/ngxs/store/pull/2289)

docs/SUMMARY.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
- [Overview](concepts/actions/README.md)
2323
- [Action Schematics](concepts/actions/schematics.md)
2424
- [Actions Life Cycle](concepts/actions/actions-life-cycle.md)
25-
- [Action Handlers](concepts/actions/action-handlers.md)
25+
- [Actions Stream](concepts/actions/actions-stream.md)
2626
- [Cancellation](concepts/actions/cancellation.md)
27+
- [Dynamic Action Handlers](concepts/actions/dynamic-action-handlers.md)
2728
- [Monitoring Unhandled Actions](concepts/actions/monitoring-unhandled-actions.md)
2829
- [STATE](concepts/state/README.md)
2930
- [Overview](concepts/state/README.md)
Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
# Action Handlers
1+
# Actions Stream
22

33
Before reading this article, we advise you to become acquainted with the [actions life cycle](./actions-life-cycle.md).
44

55
Event sourcing involves modeling the state changes made by applications as an immutable sequence or “log” of events.
66
Instead of focusing on current state, you focus on the changes that have occurred over time. It is the practice of
7-
modeling your system as a sequence of events. In NGXS, we called this Action Handlers.
7+
modeling your system as a sequence of events. In NGXS, we called this the Actions Stream.
88

99
Typically actions directly correspond to state changes but it can be difficult to always make your component react
1010
based on state. As a side effect of this paradigm, we end up creating lots of intermediate state properties
11-
to do things like reset a form/etc. Action handlers let us drive our components based on state along with events
11+
to do things like reset a form/etc. The Actions Stream lets us drive our components based on state along with events
1212
that are emitted.
1313

1414
For example, if we were to have a shopping cart and we were to delete an item out of it you might want to show
1515
a notification that it was successfully removed. In a pure state driven application, you might create some kind
16-
of message array to make the dialog show up. With Action Handlers, we can respond to the action directly.
16+
of message array to make the dialog show up. With the Actions Stream, we can respond to the action directly.
1717

18-
The action handler is an Observable that receives all the actions dispatched before the state takes any action on it.
18+
The Actions Stream is an Observable that receives all the actions dispatched before the state takes any action on it.
1919

2020
Actions in NGXS also have a lifecycle. Since any potential action can be async we tag actions showing when they are "DISPATCHED", "SUCCESSFUL", "CANCELED" or "ERRORED". This gives you the ability to react to actions at different points in their existence.
2121

@@ -83,7 +83,7 @@ export const appConfig: ApplicationConfig = {
8383
};
8484
```
8585

86-
Action handlers can also be utilized in components. For example, considering the cart deletion scenario, we could use the following code:
86+
The Actions Stream can also be utilized in components. For example, considering the cart deletion scenario, we could use the following code:
8787

8888
```ts
8989
@Component({ ... })
@@ -96,7 +96,7 @@ export class CartComponent {
9696
}
9797
```
9898

99-
Also, remember to unsubscribe from the actions stream at the end:
99+
Also, remember to unsubscribe from the Actions Stream at the end:
100100

101101
```ts
102102
@Component({ ... })
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Dynamic Action Handlers
2+
3+
Sometimes you need to attach action handlers dynamically after state initialization. For example, you might want to:
4+
5+
- Add action handlers conditionally based on runtime conditions
6+
- Add action handlers for lazy-loaded modules
7+
- Add temporary action handlers that can be removed later
8+
9+
How is a Dynamic Action Handler different to what the [Actions Stream](./actions-stream.md) gives you?
10+
11+
- It gives you the ability to make changes to state from your handler
12+
- It participates in the standard [actions life cycle](./actions-life-cycle.md)
13+
- The result of the handler will affect the completion result of the action
14+
- The life cycle for the action will only complete once all dynamic handlers have completed too
15+
16+
NGXS provides the `ActionDirector` service for registering Dynamic Action Handlers.
17+
18+
## DISCLAIMER: Before you use it...
19+
20+
Please bear in mind that this is a power user feature, and should not be used as a replacement for the typical action declarations within a state.
21+
22+
- Overuse of Dynamic Action Handlers can lead to an application that is hard to understand and hard to determine the exact behavior of a state at a specific point in time
23+
- Co-location of the handlers with a state class is a massive benefit for a clean and predictable codebase. When using Dynamic Action Handlers, please consider this fact and try to honour this principle
24+
- The main intended use of this feature is for plugins and utilities that enhance state, so if you are doing something else, please check that you can't solve your problem with the simpler state constructs
25+
- Another potential use is for the lazy loading of action handler logic. This is a very specialised optimisation and should only be used if lazy loading the entire state with a route is not sufficient
26+
27+
## The ActionDirector Service
28+
29+
The `ActionDirector` allows you to attach action handlers to a state at any point after initialization and gives you the ability to detach them when no longer needed.
30+
31+
### Attaching an Action Handler
32+
33+
```ts
34+
import { ActionDirector, createSelector } from '@ngxs/store';
35+
import { inject, Injectable } from '@angular/core';
36+
37+
// State token
38+
const COUNTRIES_STATE_TOKEN = new StateToken<string[]>('countries');
39+
40+
// Action
41+
export class AddCountry {
42+
static readonly type = '[Countries] Add Country';
43+
constructor(public country: string) {}
44+
}
45+
46+
@Injectable({ providedIn: 'root' })
47+
export class CountryService {
48+
private actionDirector = inject(ActionDirector);
49+
private handle: { detach: () => void } | null = null;
50+
51+
// Attach the action handler
52+
attachCountryHandler() {
53+
if (this.handle) return; // Already attached
54+
55+
this.handle = this.actionDirector.attachAction(
56+
COUNTRIES_STATE_TOKEN,
57+
AddCountry,
58+
(ctx, action) => {
59+
// Update state
60+
ctx.setState(countries => [...countries, action.country]);
61+
}
62+
);
63+
}
64+
65+
// Detach the action handler when no longer needed
66+
detachCountryHandler() {
67+
if (this.handle) {
68+
this.handle.detach();
69+
this.handle = null;
70+
}
71+
}
72+
}
73+
```
74+
75+
### When to Use Dynamic Action Handlers
76+
77+
Dynamic action handlers are useful in several scenarios:
78+
79+
1. **Plugin systems**: Allow plugins to register their own action handlers
80+
2. **Lazy-loaded features**: Attach action handlers when a feature module is loaded
81+
3. **Temporary behaviors**: Create handlers that only exist for a specific duration
82+
4. **Conditional action handling**: Enable action handlers based on runtime conditions
83+
84+
### The detach Function
85+
86+
The `attachAction` method returns an object with a `detach` function that can be called to remove the action handler. This enables proper cleanup and prevents memory leaks.
87+
88+
```ts
89+
// Example of attaching and later detaching a handler
90+
const handle = actionDirector.attachAction(STATE_TOKEN, SomeAction, (ctx, action) => {
91+
// Handler logic
92+
});
93+
94+
// Later, when the handler is no longer needed:
95+
handle.detach();
96+
```

docs/concepts/store/error-handling.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ It is recommended to handle errors within your `@Action` function in your state:
3232
- `Update the state` to capture the error details
3333
- Ensure that the relevant selectors cater for these error states and provide information for your user to respond to the error accordingly
3434
- OR `dispatch` an action that sends the error details to the necessary state or service
35-
- This action could be picked up by an application level error state or could be picked up by a service that is listening to the action stream (see [Action Handlers](../actions/action-handlers.md))
35+
- This action could be picked up by an application level error state or could be picked up by a service that is listening to the action stream (see [Actions Stream](../actions/actions-stream.md))
3636

3737
### Non-deterministic errors:
3838

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { inject, Injectable } from '@angular/core';
2+
import {
3+
type StateToken,
4+
type ɵActionOptions,
5+
ɵNgxsActionRegistry
6+
} from '@ngxs/store/internals';
7+
import type { Observable } from 'rxjs';
8+
9+
import type { ActionDef } from './symbols';
10+
import type { StateContext } from '../symbols';
11+
import { InternalActionHandlerFactory } from '../internal/action-handler-factory';
12+
13+
@Injectable({ providedIn: 'root' })
14+
export class ActionDirector {
15+
private _registry = inject(ɵNgxsActionRegistry);
16+
private _actionHandlerFactory = inject(InternalActionHandlerFactory);
17+
18+
attachAction<TStateModel, TActionType extends ActionDef>(
19+
stateToken: StateToken<TStateModel>,
20+
Action: TActionType,
21+
handlerFn: (
22+
ctx: StateContext<TStateModel>,
23+
action: InstanceType<TActionType>
24+
) => void | Observable<void> | Promise<void>,
25+
options: ɵActionOptions = {}
26+
) {
27+
const actionHandler = this._actionHandlerFactory.createActionHandler(
28+
stateToken.getName(),
29+
handlerFn,
30+
options
31+
);
32+
this._registry.register(Action.type, actionHandler);
33+
}
34+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { inject, Injectable, ɵisPromise } from '@angular/core';
2+
import {
3+
defaultIfEmpty,
4+
finalize,
5+
from,
6+
isObservable,
7+
mergeMap,
8+
type Observable,
9+
of,
10+
takeUntil
11+
} from 'rxjs';
12+
import type { ɵActionOptions } from '@ngxs/store/internals';
13+
14+
import { InternalActions } from '../actions-stream';
15+
import { ofActionDispatched } from '../operators/of-action';
16+
import { StateContextFactory } from './state-context-factory';
17+
import type { StateContext } from '../symbols';
18+
19+
@Injectable({ providedIn: 'root' })
20+
export class InternalActionHandlerFactory {
21+
private readonly _actions = inject(InternalActions);
22+
private readonly _stateContextFactory = inject(StateContextFactory);
23+
24+
createActionHandler(
25+
path: string,
26+
handlerFn: (ctx: StateContext<any>, action: any) => any,
27+
options: ɵActionOptions
28+
): (action: any) => Observable<any> {
29+
const { dispatched$ } = this._actions;
30+
31+
return (action: any) => {
32+
const stateContext = this._stateContextFactory.createStateContext(path);
33+
34+
let result = handlerFn(stateContext, action);
35+
36+
// We need to use `isPromise` instead of checking whether
37+
// `result instanceof Promise`. In zone.js patched environments, `global.Promise`
38+
// is the `ZoneAwarePromise`. Some APIs, which are likely not patched by zone.js
39+
// for certain reasons, might not work with `instanceof`. For instance, the dynamic
40+
// import returns a native promise (not a `ZoneAwarePromise`), causing this check to
41+
// be falsy.
42+
if (ɵisPromise(result)) {
43+
result = from(result);
44+
}
45+
46+
if (isObservable(result)) {
47+
result = result.pipe(
48+
mergeMap(value => (ɵisPromise(value) || isObservable(value) ? value : of(value))),
49+
// If this observable has completed without emitting any values,
50+
// we wouldn't want to complete the entire chain of actions.
51+
// If any observable completes, then the action will be canceled.
52+
// For instance, if any action handler had a statement like
53+
// `handler(ctx) { return EMPTY; }`, then the action would be canceled.
54+
// See https://github.com/ngxs/store/issues/1568
55+
// Note that we actually don't care about the return type; we only care
56+
// about emission, and thus `undefined` is applicable by the framework.
57+
defaultIfEmpty(undefined)
58+
);
59+
60+
if (options.cancelUncompleted) {
61+
const canceled = dispatched$.pipe(ofActionDispatched(action));
62+
result = result.pipe(takeUntil(canceled));
63+
}
64+
65+
result = result.pipe(
66+
// Note that we use the `finalize` operator only when the action handler
67+
// explicitly returns an observable (or a promise) to wait for. This means
68+
// the action handler is written in a "fire & wait" style. If the handler’s
69+
// result is unsubscribed (either because the observable has completed or
70+
// it was unsubscribed by `takeUntil` due to a new action being dispatched),
71+
// we prevent writing to the state context.
72+
finalize(() => {
73+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
74+
function noopAndWarn() {
75+
console.warn(
76+
`"${action}" attempted to change the state, but the change was ignored because state updates are not allowed after the action handler has completed.`
77+
);
78+
}
79+
80+
stateContext.setState = noopAndWarn;
81+
stateContext.patchState = noopAndWarn;
82+
} else {
83+
stateContext.setState = noop;
84+
stateContext.patchState = noop;
85+
}
86+
})
87+
);
88+
} else {
89+
// If the action handler is synchronous and returns nothing (`void`), we
90+
// still have to convert the result to a synchronous observable.
91+
result = of(undefined);
92+
}
93+
94+
return result;
95+
};
96+
}
97+
}
98+
99+
// This is used to replace `setState` and `patchState` once the action
100+
// handler has been unsubscribed or completed, to prevent writing
101+
// to the state context.
102+
function noop() {}

0 commit comments

Comments
 (0)