Skip to content

Commit 7dbf391

Browse files
feat: add withMutations(#202) [backport v19]
This introduces `withMutations` as a new SignalStore feature, with initial support for RxJS-powered mutations via `rxMutation`. Example: ```typescript export const CounterStore = signalStore( withState({ counter: 0 }), withMutations((store) => ({ increment: rxMutation({ operation: (value: number) => { return httpClient.post('counter/increase', {value}); }, onSuccess: (result) => { patchState(store, { counter: result }); } }), })), ); const counterStore = inject(CounterStore); counterStore.increment(1) console.log(counterStore.counter()) // prints 1 ``` --- ### Background - Mutations is the term the Angular team uses for the a potential upcoming feature that complements *resources* (which are read-only). Mutations enable writing data back to the server. - To align with this mutation-based mindset, we are introducing custom mutation support in the toolkit. - The design is inspired by @markostanimirovic’s prototype: https://github.com/markostanimirovic/rx-resource-proto - Thanks to Marko for consulting with the NgRx team, and to Alex Rickabaugh (@alxhub) for the core mutation design. - We also took ideas from TanStack Query's mutation feature. https://tanstack.com/query/latest --- This commit lays the foundation for further expansion of mutation support in SignalStore.
1 parent 0ea72b8 commit 7dbf391

File tree

11 files changed

+1024
-0
lines changed

11 files changed

+1024
-0
lines changed

apps/demo/src/app/app.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<a mat-list-item routerLink="/immutable-state">withImmutableState</a>
2727
<a mat-list-item routerLink="/feature-factory">withFeatureFactory</a>
2828
<a mat-list-item routerLink="/conditional">withConditional</a>
29+
<a mat-list-item routerLink="/mutation">withMutation</a>
2930
</mat-nav-list>
3031
</mat-drawer>
3132
<mat-drawer-content>

apps/demo/src/app/counter-mutation/counter-mutation.css

Whitespace-only changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<h1>withMutations</h1>
2+
3+
<div class="counter">{{ counter() }}</div>
4+
5+
<ul>
6+
<li>isPending: {{ isPending() }}</li>
7+
<li>Status: {{ status() }}</li>
8+
<li>Error: {{ error() | json }}</li>
9+
</ul>
10+
11+
<div>
12+
<button (click)="increment()">Increment</button>
13+
</div>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { CommonModule } from '@angular/common';
2+
import { Component, inject } from '@angular/core';
3+
import { CounterStore } from './counter.store';
4+
5+
@Component({
6+
selector: 'demo-counter-mutation',
7+
imports: [CommonModule],
8+
templateUrl: './counter-mutation.html',
9+
styleUrl: './counter-mutation.css',
10+
})
11+
export class CounterMutation {
12+
private store = inject(CounterStore);
13+
14+
protected counter = this.store.counter;
15+
protected error = this.store.incrementError;
16+
protected isPending = this.store.incrementIsPending;
17+
protected status = this.store.incrementStatus;
18+
19+
increment() {
20+
this.store.increment({ value: 1 });
21+
}
22+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
concatOp,
3+
rxMutation,
4+
withMutations,
5+
} from '@angular-architects/ngrx-toolkit';
6+
import { patchState, signalStore, withState } from '@ngrx/signals';
7+
import { delay, Observable } from 'rxjs';
8+
9+
export type Params = {
10+
value: number;
11+
};
12+
13+
export const CounterStore = signalStore(
14+
{ providedIn: 'root' },
15+
withState({ counter: 0 }),
16+
withMutations((store) => ({
17+
increment: rxMutation({
18+
operation: (params: Params) => {
19+
return calcSum(store.counter(), params.value);
20+
},
21+
operator: concatOp,
22+
onSuccess: (result) => {
23+
console.log('result', result);
24+
patchState(store, { counter: result });
25+
},
26+
onError: (error) => {
27+
console.error('Error occurred:', error);
28+
},
29+
}),
30+
})),
31+
);
32+
33+
let error = false;
34+
35+
function createSumObservable(a: number, b: number): Observable<number> {
36+
return new Observable<number>((subscriber) => {
37+
const result = a + b;
38+
39+
if ((result === 7 || result === 13) && !error) {
40+
subscriber.error({ message: 'error due to bad luck!', result });
41+
error = true;
42+
} else {
43+
subscriber.next(result);
44+
error = false;
45+
}
46+
subscriber.complete();
47+
});
48+
}
49+
50+
function calcSum(a: number, b: number): Observable<number> {
51+
// return of(a + b);
52+
return createSumObservable(a, b).pipe(delay(500));
53+
}

apps/demo/src/app/lazy-routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,11 @@ export const lazyRoutes: Route[] = [
6161
(m) => m.ConditionalSettingComponent,
6262
),
6363
},
64+
{
65+
path: 'mutation',
66+
loadComponent: () =>
67+
import('./counter-mutation/counter-mutation').then(
68+
(m) => m.CounterMutation,
69+
),
70+
},
6471
];

libs/ngrx-toolkit/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,14 @@ export {
4141
} from './lib/storage-sync/with-storage-sync';
4242
export { emptyFeature, withConditional } from './lib/with-conditional';
4343
export { withFeatureFactory } from './lib/with-feature-factory';
44+
45+
export * from './lib/rx-mutation';
46+
export * from './lib/with-mutations';
4447
export { mapToResource, withResource } from './lib/with-resource';
48+
49+
export {
50+
concatOp,
51+
exhaustOp,
52+
mergeOp,
53+
switchOp,
54+
} from './lib/flattening-operator';
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {
2+
concatMap,
3+
exhaustMap,
4+
mergeMap,
5+
ObservableInput,
6+
ObservedValueOf,
7+
OperatorFunction,
8+
switchMap,
9+
} from 'rxjs';
10+
11+
export type RxJsFlatteningOperator = <T, O extends ObservableInput<unknown>>(
12+
project: (value: T, index: number) => O,
13+
) => OperatorFunction<T, ObservedValueOf<O>>;
14+
15+
/**
16+
* A wrapper for an RxJS flattening operator.
17+
* This wrapper informs about whether the operator has exhaust semantics or not.
18+
*/
19+
export type FlatteningOperator = {
20+
rxJsOperator: RxJsFlatteningOperator;
21+
exhaustSemantics: boolean;
22+
};
23+
24+
export const switchOp: FlatteningOperator = {
25+
rxJsOperator: switchMap,
26+
exhaustSemantics: false,
27+
};
28+
29+
export const mergeOp: FlatteningOperator = {
30+
rxJsOperator: mergeMap,
31+
exhaustSemantics: false,
32+
};
33+
34+
export const concatOp: FlatteningOperator = {
35+
rxJsOperator: concatMap,
36+
exhaustSemantics: false,
37+
};
38+
39+
export const exhaustOp: FlatteningOperator = {
40+
rxJsOperator: exhaustMap,
41+
exhaustSemantics: true,
42+
};
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { computed, DestroyRef, inject, Injector, signal } from '@angular/core';
2+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
3+
import {
4+
catchError,
5+
defer,
6+
EMPTY,
7+
finalize,
8+
Observable,
9+
Subject,
10+
tap,
11+
} from 'rxjs';
12+
13+
import { concatOp, FlatteningOperator } from './flattening-operator';
14+
import { Mutation, MutationResult, MutationStatus } from './with-mutations';
15+
16+
export type Func<P, R> = (params: P) => R;
17+
18+
export interface RxMutationOptions<P, R> {
19+
operation: Func<P, Observable<R>>;
20+
onSuccess?: (result: R, params: P) => void;
21+
onError?: (error: unknown, params: P) => void;
22+
operator?: FlatteningOperator;
23+
injector?: Injector;
24+
}
25+
26+
/**
27+
* Creates a mutation that leverages RxJS.
28+
*
29+
* For each mutation the following options can be defined:
30+
* - `operation`: A function that defines the mutation logic. It returns an Observable.
31+
* - `onSuccess`: A callback that is called when the mutation is successful.
32+
* - `onError`: A callback that is called when the mutation fails.
33+
* - `operator`: An optional wrapper of an RxJS flattening operator. By default `concat` sematics are used.
34+
* - `injector`: An optional Angular injector to use for dependency injection.
35+
*
36+
* The `operation` is the only mandatory option.
37+
*
38+
* ```typescript
39+
* export type Params = {
40+
* value: number;
41+
* };
42+
*
43+
* export const CounterStore = signalStore(
44+
* { providedIn: 'root' },
45+
* withState({ counter: 0 }),
46+
* withMutations((store) => ({
47+
* increment: rxMutation({
48+
* operation: (params: Params) => {
49+
* return calcSum(store.counter(), params.value);
50+
* },
51+
* operator: concatOp,
52+
* onSuccess: (result) => {
53+
* console.log('result', result);
54+
* patchState(store, { counter: result });
55+
* },
56+
* onError: (error) => {
57+
* console.error('Error occurred:', error);
58+
* },
59+
* }),
60+
* })),
61+
* );
62+
*
63+
* function calcSum(a: number, b: number): Observable<number> {
64+
* return of(a + b);
65+
* }
66+
* ```
67+
*
68+
* @param options
69+
* @returns
70+
*/
71+
export function rxMutation<P, R>(
72+
options: RxMutationOptions<P, R>,
73+
): Mutation<P, R> {
74+
const inputSubject = new Subject<{
75+
param: P;
76+
resolve: (result: MutationResult<R>) => void;
77+
}>();
78+
const flatteningOp = options.operator ?? concatOp;
79+
80+
const destroyRef = options.injector?.get(DestroyRef) ?? inject(DestroyRef);
81+
82+
const callCount = signal(0);
83+
const errorSignal = signal<unknown>(undefined);
84+
const idle = signal(true);
85+
const isPending = computed(() => callCount() > 0);
86+
87+
const status = computed<MutationStatus>(() => {
88+
if (idle()) {
89+
return 'idle';
90+
}
91+
if (callCount() > 0) {
92+
return 'pending';
93+
}
94+
if (errorSignal()) {
95+
return 'error';
96+
}
97+
return 'success';
98+
});
99+
100+
const initialInnerStatus: MutationStatus = 'idle';
101+
let innerStatus: MutationStatus = initialInnerStatus;
102+
let lastResult: R;
103+
104+
inputSubject
105+
.pipe(
106+
flatteningOp.rxJsOperator((input) =>
107+
defer(() => {
108+
callCount.update((c) => c + 1);
109+
idle.set(false);
110+
return options.operation(input.param).pipe(
111+
tap((result: R) => {
112+
options.onSuccess?.(result, input.param);
113+
innerStatus = 'success';
114+
errorSignal.set(undefined);
115+
lastResult = result;
116+
}),
117+
catchError((error: unknown) => {
118+
options.onError?.(error, input.param);
119+
errorSignal.set(error);
120+
innerStatus = 'error';
121+
return EMPTY;
122+
}),
123+
finalize(() => {
124+
callCount.update((c) => c - 1);
125+
126+
if (innerStatus === 'success') {
127+
input.resolve({
128+
status: 'success',
129+
value: lastResult,
130+
});
131+
} else if (innerStatus === 'error') {
132+
input.resolve({
133+
status: 'error',
134+
error: errorSignal(),
135+
});
136+
} else {
137+
input.resolve({
138+
status: 'aborted',
139+
});
140+
}
141+
142+
innerStatus = initialInnerStatus;
143+
}),
144+
);
145+
}),
146+
),
147+
takeUntilDestroyed(destroyRef),
148+
)
149+
.subscribe();
150+
151+
const mutationFn = (param: P) => {
152+
return new Promise<MutationResult<R>>((resolve) => {
153+
if (callCount() > 0 && flatteningOp.exhaustSemantics) {
154+
resolve({
155+
status: 'aborted',
156+
});
157+
} else {
158+
inputSubject.next({
159+
param,
160+
resolve,
161+
});
162+
}
163+
});
164+
};
165+
166+
const mutation = mutationFn as Mutation<P, R>;
167+
mutation.status = status;
168+
mutation.isPending = isPending;
169+
mutation.error = errorSignal;
170+
171+
return mutation;
172+
}

0 commit comments

Comments
 (0)