Skip to content

Commit

Permalink
feat: dispatch accepts a function
Browse files Browse the repository at this point in the history
`dispatch` now accepts a function that returns an `Action`. Providing a `Signal<Action>` will still be possible (`Signal` is also a function).

I had to rename the type `FunctionIsNotAllowed` to `NoActionCreator`.

Previously, `FunctionIsNotAllowed` was used to prevent passing functions to `dispatch`, but the primary intent was to forbid `ActionCreator`.

```typescript
store.dispatch(loadBook); // 👎
store.dispatch(loadBook({id: 1})); // 👍
```

As the name says, `NoActionCreator` now explicitly forbids only `ActionCreator`. Functions are fine. Otherwise, the new feature would not be possible.
  • Loading branch information
rainerhahnekamp committed Nov 22, 2024
1 parent 5c040ac commit 8ecac43
Show file tree
Hide file tree
Showing 5 changed files with 43 additions and 44 deletions.
28 changes: 14 additions & 14 deletions modules/store/spec/store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
computed,
createEnvironmentInjector,
EnvironmentInjector,
InjectionToken,
Expand All @@ -18,6 +17,8 @@ import {
UPDATE,
ActionReducer,
Action,
createAction,
props,
} from '../';
import { StoreConfig } from '../src/store_config';
import { combineReducers } from '../src/utils';
Expand Down Expand Up @@ -717,10 +718,7 @@ describe('ngRx Store', () => {
store = TestBed.inject(Store);

const inputId = signal(1);
const incrementerAction = computed(() => ({
type: INCREMENT,
id: inputId(),
}));
const increment = createAction('INCREMENT', props<{ id: number }>());

const changeInputIdAndFlush = () => {
inputId.update((value) => value + 1);
Expand All @@ -729,16 +727,16 @@ describe('ngRx Store', () => {

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

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

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

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

store.dispatch(incrementerAction);
store.dispatch(() => increment({ id: inputId() }));
TestBed.flushEffects();
expect(stateSignal()).toBe(1);

Expand All @@ -756,10 +754,10 @@ describe('ngRx Store', () => {
});

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

const ref = store.dispatch(incrementerAction);
const ref = store.dispatch(() => increment({ id: inputId() }));
TestBed.flushEffects();

ref.destroy();
Expand All @@ -768,15 +766,15 @@ describe('ngRx Store', () => {
});

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

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

TestBed.flushEffects();
Expand All @@ -788,13 +786,15 @@ describe('ngRx Store', () => {
});

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

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

TestBed.flushEffects();
Expand Down
2 changes: 1 addition & 1 deletion modules/store/spec/types/store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('Store', () => {

it('should not allow passing action creator function without calling it', () => {
expectSnippet(`store.dispatch(fooAction);`).toFail(
/is not assignable to type '"Functions are not allowed to be dispatched. Did you forget to call the action creator function/
/is not assignable to type '"action creators are not allowed to be dispatched. Did you forget to call it/
);
});
});
10 changes: 6 additions & 4 deletions modules/store/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,12 @@ export const primitivesAreNotAllowedInProps =
'action creator props cannot be a primitive value';
type PrimitivesAreNotAllowedInProps = typeof primitivesAreNotAllowedInProps;

export type FunctionIsNotAllowed<
T,
ErrorMessage extends string
> = T extends Function ? ErrorMessage : T;
export type NoActionCreator<T> = T extends Action
? T extends () => Action
? 'action creators are not allowed to be dispatched. Did you forget to call it?'
: T
: T;

/**
* A function that returns an object in the shape of the `Action` interface. Configured using `createAction`.
*/
Expand Down
38 changes: 19 additions & 19 deletions modules/store/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
inject,
Injectable,
Injector,
isSignal,
Provider,
Signal,
untracked,
Expand All @@ -18,8 +17,8 @@ import { ActionsSubject } from './actions_subject';
import {
Action,
ActionReducer,
NoActionCreator,
SelectSignalOptions,
FunctionIsNotAllowed,
} from './models';
import { ReducerManager } from './reducer_manager';
import { StateObservable } from './state';
Expand Down Expand Up @@ -137,23 +136,18 @@ export class Store<T = object>
return store;
}

dispatch<V extends Action = Action>(
action: V &
FunctionIsNotAllowed<
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?'
>,
dispatch<V extends Action>(action: V & NoActionCreator<V>): void;
dispatch<V extends () => Action>(
actionFn: V & NoActionCreator<V>,
config?: {
injector: Injector;
}
): EffectRef;
dispatch<V extends Action | (() => Action)>(
action: V & NotActionCreator<V, 'ActionCreators are not allowed'>,
config?: { injector: Injector }
): EffectRef | void {
if (isSignal(action)) {
if (typeof action === 'function') {
return this.processSignalToDispatch(action, config);
}
this.actionsObserver.next(action);
Expand Down Expand Up @@ -183,7 +177,7 @@ export class Store<T = object>
}

private processSignalToDispatch(
actionSignal: Signal<Action>,
actionFn: () => Action,
config?: { injector: Injector }
) {
assertNotUndefined(this.injector);
Expand All @@ -192,7 +186,7 @@ export class Store<T = object>

return effect(
() => {
const action = actionSignal();
const action = actionFn();
untracked(() => {
this.dispatch(action);
});
Expand Down Expand Up @@ -324,3 +318,9 @@ function getCallerInjector() {
return undefined;
}
}

type NotActionCreator<T, ErrorMessage> = T extends Action
? T extends () => Action
? ErrorMessage
: T
: T;
9 changes: 3 additions & 6 deletions projects/ngrx.io/content/guide/store/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,9 @@ 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);
store.dispatch(() => loadBook({ id: this.bookId() })));
}
}
</code-example>
Expand All @@ -109,12 +108,11 @@ 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});
store.dispatch(() => loadBook({ id: this.bookId() }), {injector: this.injector});
}
}
</code-example>
Expand All @@ -124,12 +122,11 @@ class BookComponent {
<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 the injection context of Store, i.e. root injector
this.loadBookEffectRef = store.dispatch(this.loadBookAction);
this.loadBookEffectRef = store.dispatch(() => loadBook({ id: this.bookId() }));
}

ngOnDestroy() {
Expand Down

0 comments on commit 8ecac43

Please sign in to comment.