Skip to content

Commit

Permalink
feat(store): add possibility to dispatch Signal<Action>
Browse files Browse the repository at this point in the history
A type of Signal<Action> can be passed to dispatch:

```typescript
class BookComponent {
  bookId = input.required<number>();
  bookAction = computed(() => loadBook({id: this.bookId()}));

  store = inject(Store);

  constructor() {
    this.store.dispatch(this.bookAction);
  }
}
```
The benefit is that users no longer need to use an effect to track the Signal.
The Store handles this internally.

If `dispatch` receives a `Signal` it returns an `EffectRef`, allowing manual
destruction.

By default, the injection context of the caller is used. If the call happens
outside an injection context, the Store will use its own injection context,
which is usually the root injector.

It is also possible to provide a custom injector as the second parameter:
```typescript
this.store.dispatch(this.bookAction, {injector: this.injector});
```
  • Loading branch information
rainerhahnekamp committed Nov 20, 2024
1 parent 698f0b7 commit 58da96d
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 4 deletions.
104 changes: 103 additions & 1 deletion modules/store/spec/store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { InjectionToken } from '@angular/core';
import {
computed,
createEnvironmentInjector,
EnvironmentInjector,
InjectionToken,
runInInjectionContext,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { hot } from 'jasmine-marbles';
import {
Expand Down Expand Up @@ -703,4 +710,99 @@ describe('ngRx Store', () => {
expect(metaReducerSpy2).toHaveBeenCalledWith(counterReducer2);
});
});

describe('Signal Dispatcher', () => {
const setupForSignalDispatcher = () => {
setup();
store = TestBed.inject(Store);

const inputId = signal(1);
const incrementerAction = computed(() => ({
type: INCREMENT,
id: inputId(),
}));

const changeInputIdAndFlush = () => {
inputId.update((value) => value + 1);
TestBed.flushEffects();
};

const stateSignal = store.selectSignal((state) => state.counter1);

return { inputId, incrementerAction, stateSignal, changeInputIdAndFlush };
};

it('should dispatch upon Signal change', () => {
const { inputId, incrementerAction, changeInputIdAndFlush, stateSignal } =
setupForSignalDispatcher();

expect(stateSignal()).toBe(0);

store.dispatch(incrementerAction);
TestBed.flushEffects();
expect(stateSignal()).toBe(1);

changeInputIdAndFlush();
expect(stateSignal()).toBe(2);

inputId.update((value) => value + 1);
expect(stateSignal()).toBe(2);

TestBed.flushEffects();
expect(stateSignal()).toBe(3);

TestBed.flushEffects();
expect(stateSignal()).toBe(3);
});

it('should stop dispatching once the effect is destroyed', () => {
const { incrementerAction, changeInputIdAndFlush, stateSignal } =
setupForSignalDispatcher();

const ref = store.dispatch(incrementerAction);
TestBed.flushEffects();

ref.destroy();
changeInputIdAndFlush();
expect(stateSignal()).toBe(1);
});

it('should use the injectionContext of the caller if available', () => {
const { incrementerAction, changeInputIdAndFlush, stateSignal } =
setupForSignalDispatcher();

const callerContext = createEnvironmentInjector(
[],
TestBed.inject(EnvironmentInjector)
);
runInInjectionContext(callerContext, () =>
store.dispatch(incrementerAction)
);

TestBed.flushEffects();
expect(stateSignal()).toBe(1);

callerContext.destroy();
changeInputIdAndFlush();
expect(stateSignal()).toBe(1);
});

it('should allow to override the injectionContext of the caller', () => {
const { incrementerAction, changeInputIdAndFlush, stateSignal } =
setupForSignalDispatcher();

const environmentInjector = TestBed.inject(EnvironmentInjector);
const callerContext = createEnvironmentInjector([], environmentInjector);
runInInjectionContext(callerContext, () =>
store.dispatch(incrementerAction, { injector: environmentInjector })
);

TestBed.flushEffects();
expect(stateSignal()).toBe(1);

callerContext.destroy();
changeInputIdAndFlush();
expect(stateSignal()).toBe(2);
});
});
});
9 changes: 9 additions & 0 deletions modules/store/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ export function capitalize<T extends string>(text: T): Capitalize<T> {
export function uncapitalize<T extends string>(text: T): Uncapitalize<T> {
return (text.charAt(0).toLowerCase() + text.substring(1)) as Uncapitalize<T>;
}

export function assertNotUndefined<T>(
value: T | undefined,
message = `${value} must not be undefined`
): asserts value is T {
if (value === undefined) {
throw new Error(message);
}
}
58 changes: 55 additions & 3 deletions modules/store/src/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
// disabled because we have lowercase generics for `select`
import { computed, Injectable, Provider, Signal } from '@angular/core';
import {
computed,
effect,
EffectRef,
inject,
Injectable,
Injector,
isSignal,
Provider,
Signal,
untracked,
} from '@angular/core';
import { Observable, Observer, Operator } from 'rxjs';
import { distinctUntilChanged, map, pluck } from 'rxjs/operators';

Expand All @@ -12,6 +23,7 @@ import {
} from './models';
import { ReducerManager } from './reducer_manager';
import { StateObservable } from './state';
import { assertNotUndefined } from './helpers';

@Injectable()
export class Store<T = object>
Expand All @@ -26,7 +38,8 @@ export class Store<T = object>
constructor(
state$: StateObservable,
private actionsObserver: ActionsSubject,
private reducerManager: ReducerManager
private reducerManager: ReducerManager,
private injector?: Injector
) {
super();

Expand Down Expand Up @@ -130,7 +143,19 @@ export class Store<T = object>
V,
'Functions are not allowed to be dispatched. Did you forget to call the action creator function?'
>
) {
): void;
dispatch(action: Signal<Action>, config?: { injector: Injector }): EffectRef;
dispatch<V extends Action = Action>(
action: (V | Signal<V>) &
FunctionIsNotAllowed<
V,
'Functions are not allowed to be dispatched. Did you forget to call the action creator function?'
>,
config?: { injector: Injector }
): EffectRef | void {
if (isSignal(action)) {
return this.processSignalToDispatch(action, config);
}
this.actionsObserver.next(action);
}

Expand All @@ -156,6 +181,25 @@ export class Store<T = object>
removeReducer<Key extends Extract<keyof T, string>>(key: Key) {
this.reducerManager.removeReducer(key);
}

private processSignalToDispatch(
actionSignal: Signal<Action>,
config?: { injector: Injector }
) {
assertNotUndefined(this.injector);
const effectInjector =
config?.injector ?? getCallerInjector() ?? this.injector;

return effect(
() => {
const action = actionSignal();
untracked(() => {
this.dispatch(action);
});
},
{ injector: effectInjector }
);
}
}

export const STORE_PROVIDERS: Provider[] = [Store];
Expand Down Expand Up @@ -272,3 +316,11 @@ export function select<T, Props, K>(
return mapped$.pipe(distinctUntilChanged());
};
}

function getCallerInjector() {
try {
return inject(Injector);
} catch (_) {
return undefined;
}
}
56 changes: 56 additions & 0 deletions projects/ngrx.io/content/guide/store/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,62 @@ The returned action has very specific context about where the action came from a

</div>

## Signal Actions

There is also the option to dispatch a Signal of type `Signal<Action>`:

<code-example header="book.component.ts">
class BookComponent {
bookId = input.required&lt;number&gt;();
loadBookAction = computed(() => loadBook({ id: this.bookId() }));

constructor(store: Store) {
store.dispatch(this.loadBookAction);
}
}
</code-example>

`dispatch` executes initially and every time the `bookId` changes. If `dispatch` is called within an injection context, the Signal will be tracked until context is destroyed. In the example above, that would be when `BookingComponent` is destroyed.

If `dispatch` is called outside an injection context, the Signal will be tracked during the whole application lifecycle.

Alternatively, you can provide your own injection context:

<code-example header="book.component.ts">
class BookComponent {
bookId = input.required&lt;number&gt;();
loadBookAction = computed(() => loadBook({ id: this.bookId() }));
injector = inject(Injector);

ngOnInit(store: Store) {
// runs outside the injection context
store.dispatch(this.loadBookAction, {injector: this.injector});
}
}
</code-example>

`dispatch` will return an `EffectRef` if you provide a Signal. You can manually destroy the effect by calling `destroy` on the `EffectRef`.

<code-example header="book.component.ts">
class BookComponent {
bookId = input.required&lt;number&gt;();
loadBookAction = computed(() => loadBook({ id: this.bookId() }));
loadBookEffectRef: EffectRef | undefined;

ngOnInit(store: Store) {
// uses injection context of Store, i.e. root injector
this.loadBookEffectRef = store.dispatch(this.loadBookAction);
}

ngOnDestroy() {
if (this.loadBookEffectRef) {
// destroys the effect
this.loadBookEffectRef.destroy();
}
}
}
</code-example>

## Next Steps

Action's only responsibilities are to express unique events and intents. Learn how they are handled in the guides below.
Expand Down

0 comments on commit 58da96d

Please sign in to comment.