Mutations
import { httpMutation } from '@angular-architects/ngrx-toolkit';
import { rxMutation } from '@angular-architects/ngrx-toolkit';
import { withMutations } from '@angular-architects/ngrx-toolkit';
// Optional, `concatOp` is the default.
import { concatOp, exhaustOp, mergeOp, switchOp } from '@angular-architects/ngrx-toolkit';
Basic Usageβ
The mutations feature (withMutations
) and methods (httpMutation
and rxMutation
) seek to offer an appropriate equivalent to signal resources for sending data back to the backend. The methods can be used in withMutations()
but can be used outside of a store in something like a component or service as well.
This guide covers
- Why we do not use
withResource
, and the direction on mutations from the community - Key Features (summary and in depth):
- The params to pass (via RxJS or via
HttpClient
params without RxJS) - Callbacks available (
onSuccess
andonError
) - Flattening operators (
concatOp, exhaustOp, mergeOp, switchOp
) - Calling the mutations (optionally as
Promise
) - State signals available (
value/status/error/isPending
)- For
httpMutation
, the response type is specified with the paramparse: (res: T) => res as T
hasValue
signal to narrow type. NOTE: currently there is an outstanding bug that this does not properly narrow.
- For
- How to use, as:
- standalone functions
- In
withMutations
store feature
- The params to pass (via RxJS or via
- Differences between
httpMutation
andrxMutation
- Full examples of
- Both mutations in a
withMutations()
- Standalone functions in a component
- Both mutations in a
withMutations((store) => ({
increment: rxMutation({
operation: (params: Params) => {
return calcSum(store.counter(), params.value);
},
onSuccess: (result) => {
// ...
},
onError: (error) => {
// ...
},
}),
saveToServer: httpMutation({
request: (_: void) => ({
url: `https://httpbin.org/post`,
method: 'POST',
body: { counter: store.counter() },
}),
parse: (response) => response as CounterResponse,
onSuccess: (response) => {
console.log('Counter sent to server:', response);
patchState(store, { lastResponse: response.json });
},
onError: (error) => {
console.error('Failed to send counter:', error);
},
}),
})),
But before going into depth of the "How" and "When" to use mutations, it is important to give context about the "Why" and "Who" of why mutations were built for the toolkit like this.
Backgroundβ
Why not handle mutations using withResource
?β
The resource
API and discussion about it naturally lead to talks about all async operations.
Notably, one position has been remained firm by the Angular team through resources' debut, RFCs (#1, Architecture) and (#2, APIs), and followup
enhancements: Resources should only be responsible for read operations, such as an HTTP GET. Resources should NOT be used for MUTATIONS,
for example, HTTP methods like POST/PUT/DELETE. Though other HTTP methods are supported in resources, there are edge cases for those who need them, such as some APIs that treat everything as a POST request π¬
"
httpResource
(and the more fundamentalresource
) both declare a dependency on data that should be fetched. It's not a suitable primitive for making imperative HTTP requests, such as requests to mutation APIs" - Pawel Kozlowski, in the Resource API RFC
Path the toolkit is following for Mutationsβ
Libraries like Angular Query offer a Mutation API for such cases. Some time ago, Marko StanimiroviΔ also proposed a Mutation API for Angular. These mutation functions and features are heavily inspired by Marko's work and adapts it as a custom feature/functions for the NgRx SignalStore. We also had internal discussions with Alex Rickabaugh on our design.
The goal is to provide a simple Mutation API that is available now for early adopters. Ideally, migration to future mutation APIs will be straightforward. Hence, we aim to align with current ideas for them (if any).
Key features (summary)β
Each mutation has the following:
- Parameters to pass to an RxJS stream (
rxMutation
) or RxJS agnosticHttpClient
call (httpMutation
) - Callbacks:
onSuccess
andonError
(optional) - Flattening operators (optional, defaults to
concatOp
) - Provides a factory function of the same name as the mutation, returns a
Promise
. - State signals:
value/status/error/isPending/hasValue
Additionally, mutations can be used in either withMutations()
or as standalone functions.
Paramsβ
See dedicated section on choosing between rxMutation
and httpMutation
// RxJS stream
rxMutation({
operation: (params: Params) => {
// function calcSum(a: number, b: number): Observable<number>
return calcSum(this.counterSignal(), params.value);
},
})
// http call, as options
httpMutation((userData: CreateUserRequest) => ({
url: '/api/users',
method: 'POST',
body: userData,
})),
// OR
// http call, as function + options
httpMutation({
request: (p: Params) => ({
url: `https://httpbin.org/post`,
method: 'POST',
body: { counter: p.value },
}),
parse: (res) => res as CounterResponse,
);
Callbacksβ
In the mutation: optional onSuccess
and onError
callbacks
({
onSuccess: (result: CounterResponse) => {
// optional
// method:
// this.counterSignal.set(result);
// store:
// patchState(store, {counter: result});
},
onError: (error) => {
// optional
console.error('Error occurred:', error);
},
});
Flattening operatorsβ
Enables handling race conditions
// Default: concatOp
// All options: concatOp, exhaustOp, mergeOp, switchOp
increment: rxMutation({
// ...
// Passing in a custom option. Need to import like:
// import { switchOp } from '@angular-architects/ngrx-toolkit'
operator: mergeOp, // `concatOp` is the default if `operator` is omitted
}),
saveToServer: httpMutation({
// ...
operator: switchOp,
}),
Since a mutation returns a Promise
, we would not be able to know if a request got skipped with native exhaustMap
. That's why we are providing adapters which would just pass-through on merge/switch/concatMap
but resolve the resulting Promise
in exhaustMap
if it would be skipped.
We considered doing an object reference check internally (operator === exhaustMap
) which would have removed the necessity for the adaptors. The reason why we decided against it was tree-shakability. Once rxMutation
imports exhaustMap
for the check, it will always be there (even if it is not used).
Methodsβ
Enables the method (returns a Promise
)
// Call directly
store.increment({...});
mutationName.saveToServer({...});
// or await `Promise`s
const inc = await store.increment({...}); if (inc.status === 'success')
const save = await store.save({...}); if (inc.status === 'error')
Signal valuesβ
// Signals
// via store
store.increment.value; // also status/error/isPending/status/hasValue;
// via member variable
mutationName.value; // ^^^
Usage: withMutations()
or solo functionsβ
Both of the mutation functions can be used either
- In a
signalStore
, inside ofwithMutations()
- On its own, for example, like a class member of a component or service
Independent of a storeβ
@Component({...})
class CounterMutation {
private increment = rxMutation({...});
private saveToServer = httpMutation({...});
}
Inside withMutations()
β
export const CounterStore = signalStore(
// ...
withMutations((store) => ({
// the same functions
increment: rxMutation({...}),
saveToServer: httpMutation({...}),
})),
);
Usage - In Depthβ
The mutation functions can be used in a withMutations()
feature, but can be used outside of one in something like a component or service as well.
Key features (in depth)β
Each mutation has the following:
- Passing params via RxJS or RxJS-less
HttpClient
signature - State signals:
value/status/error/isPending/status/hasValue
- For
httpMutation
, the response type is specified with the paramparse: (res: T) => res as T
- For
- (optional, but has default) Flattening operators
- (optional) callbacks:
onSuccess
andonError
- Provides a factory function of the same name as the mutation, returns a
Promise
.
State Signalsβ
// Fields + types types:
export type MutationStatus = 'idle' | 'pending' | 'error' | 'success';
export type Mutation<Parameter, Result> = {
status: Signal<'idle' | 'pending' | 'error' | 'success'>;
value: Signal<Result | undefined>;
isPending: Signal<boolean>;
isSuccess: Signal<boolean>;
error: Signal<unknown>;
hasValue(): this is Mutation<Exclude<Parameter, undefined>, Result>; // type narrows `.value()`
};
// Accessed from store or variable
storeName.mutationName.value; // or other signals
mutationName.value; // ^^^
Callbacks: onSuccess
and onError
(optional)β
Callbacks can be used on success or error of the mutation. This allows for side effects, such as patching/setting state like a service's signal or a store's property.
export const CounterStore = signalStore(
// ...
withMutations((store) => ({
increment: rxMutation({
// ...
onSuccess: (result: CounterResponse) => {
console.log('result', result);
patchState(store, { counter: result });
},
}),
})),
);
@Component({...})
class CounterMutation {
// ...
private saveToServer = httpMutation({
// ...
onError: (error) => {
console.error('Failed to send counter:', error);
},
});
}
Flattening operators (optional to specify, has default)β
// Default: concatOp
// All options: concatOp, exhaustOp, mergeOp, switchOp
(withMutations((store) => ({
increment: rxMutation({
// ...
// Passing in a custom option. Need to import like:
// import { switchOp } from '@angular-architects/ngrx-toolkit'
operator: mergeOp, // `concatOp` is the default if `operator` is omitted
}),
})),
class SomeComponent {
private saveToServer = httpMutation({
// ...
operator: switchOp,
});
});
Methodsβ
A mutation is its own function to be invoked, returning a Promise
should you want to await one.
@Component({...})
class CounterRxMutation {
private increment = rxMutation({...});
private store = inject(CounterStore);
// To await
async incrementBy13() {
const resultA = await this.increment({ value: 13 });
if (resultA.status === 'success') { ... }
const resultB = await this.store.increment({ value: 13 });
if (resultB.status === 'success') { ... }
}
// or not to await, that is the question
incrementBy12() {
this.increment({ value: 12 });
this.store.increment({ value: 12 });
}
}
Why do we return a Promise
and not something else, like an Observable
or Signal
?
We were looking at the use case for showing a message,
navigating to a different route, or showing/hiding a loading indicator while the mutation is active or ends. If we use a Signal
, then it
could be that a former mutation already set the value successful on the status. If we would have an effect
, waiting for the Signal
to
succeed, that one would run immediately. Observable
would have the same problem, and it would also add to the API which
exposes an Observable
which means users have to deal with RxJS once more. A Promise
is perfect. It guarantees to return just a single
value where Observable
can emit one, none or multiple. It is always asynchronous and not like Observable
. The syntax with await
makes it quite good for DX and it is very easy to go from a Promise
to an Observable
or even Signal
.
Choosing between rxMutation
and httpMutation
β
Though mutations and resources have different intents, the difference between rxMutation
and httpMutation
can be seen in a
similar way as rxResource
and httpResource
For brevity, take rx
as rxMutation
and http
for httpMutation
rx
to utilize RxJS streams,http
to make anHttpClient
requestrx
could be any validObservable
, even if it is not HTTP related.http
has to be an HTTP request. The user's API is agnostic of RxJS. Technically, HttpClient withObservable
s is used under the hood.
- Primary property to pass parameters to:
rx
'soperation
is a function that defines the mutation logic. It returns anObservable
,http
takes parts ofHttpClient
's method signature, or arequest
object which accepts those parts
Full exampleβ
Our example application in the repository has more details and implementations, but here is a full example in a store using withMutations
.
This example is a dedicated store with withMutations
and used in a component, but could be just the mutation functions as class members of a service/component or const
s, for example.
Declareβ
import { concatOp, httpMutation, rxMutation, withMutations } from '@angular-architects/ngrx-toolkit';
import { patchState, signalStore, withState } from '@ngrx/signals';
import { delay, Observable } from 'rxjs';
export type Params = {
value: number;
};
// httpbin.org echos the request in the json property
export type CounterResponse = {
json: { counter: number };
};
export const CounterStore = signalStore(
{ providedIn: 'root' },
withState({
counter: 0,
lastResponse: undefined as unknown | undefined,
}),
withMutations((store) => ({
increment: rxMutation({
operation: (params: Params) => {
return calcSum(store.counter(), params.value);
},
onSuccess: (result: number) => {
console.log('result', result);
patchState(store, { counter: result });
},
onError: (error) => {
console.error('Error occurred:', error);
},
}),
saveToServer: httpMutation({
request: (_: void) => ({
url: `https://httpbin.org/post`,
method: 'POST',
body: { counter: store.counter() },
}),
parse: (res) => res as CounterResponse,
onSuccess: (response) => { // response inferred as per `parse` ^^^
console.log('Counter sent to server:', response);
patchState(store, { lastResponse: response.json });
},
onError: (error) => {
console.error('Failed to send counter:', error);
},
}),
})),
);
// return of(a + b);
function calcSum(a: number, b: number): Observable<number> {
function createSumObservable(a: number, b: number): Observable<number> {...}
return createSumObservable(a, b).pipe(delay(500));
}
Useβ
@Component({...})
export class CounterMutation {
private store = inject(CounterStore);
// signals
protected counter = this.store.counter;
protected error = this.store.incrementError;
protected isPending = this.store.incrementIsPending;
protected status = this.store.incrementStatus;
// signals
protected saveError = this.store.saveToServerError;
protected saveIsPending = this.store.saveToServerIsPending;
protected saveStatus = this.store.saveToServerStatus;
protected lastResponse = this.store.lastResponse;
increment() {
this.store.increment({ value: 1 });
}
// `Promise` version nice if you want to the result's `status`
async saveToServer() {
await this.store.saveToServer();
}
}