From 91842734297db27d7735009afc55482da3e8eff5 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 19:54:17 -0500 Subject: [PATCH 01/12] docs(mutations): use inference for `httpMutation` --- docs/docs/mutations.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 4866f75..bebf329 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -27,13 +27,13 @@ This guide covers - Why we do not use [`withResource`](./with-resource), and the direction on mutations from the community - Key Features ([summary](#key-features-summary) and [in depth](#key-features-in-depth)): - - The params to pass (via RxJS or via `HttpClient` params without RxJS) - Callbacks available (`onSuccess` and `onError`) - Flattening operators (`concatOp, exhaustOp, mergeOp, switchOp`) - Calling the mutations (optionally as promises) - - - State signals available (`value/status/error/isPending`) + `hasValue` signal to narrow type- `httpMutation` and `rxMutation` + - State signals available (`value/status/error/isPending`) + + - `hasValue` signal to narrow type. NOTE: currently there is an outstanding bug that this does not properly narrow. - [How to use](#usage-withmutations-or-solo-functions), as: - _standalone functions_ - In `withMutations` store _feature_ @@ -88,15 +88,15 @@ rxMutation({ }) // http call, as options -httpMutation((userData) => ({ +httpMutation((userData: CreateUserRequest) => ({ url: '/api/users', method: 'POST', body: userData, })), // OR // http call, as function + options -httpMutation({ - request: (p) => ({ +httpMutation({ + request: (p: Params) => ({ url: `https://httpbin.org/post`, method: 'POST', body: { counter: p.value }, @@ -138,7 +138,7 @@ increment: rxMutation({ operator: concatOp, // default if `operator` omitted }), -saveToServer: httpMutation({ +saveToServer: httpMutation({ // ... // Passing in a custom option. Need to import like: // `import { switchOp } from '@angular-architects/ngrx-toolkit'` @@ -185,7 +185,7 @@ Both of the mutation functions can be used either @Component({...}) class CounterMutation { private increment = rxMutation({...}); - private saveToServer = httpMutation({...}); + private saveToServer = httpMutation({...}); } ``` @@ -197,7 +197,7 @@ export const CounterStore = signalStore( withMutations((store) => ({ // the same functions increment: rxMutation({...}), - saveToServer: httpMutation({...}), + saveToServer: httpMutation({...}), })), ); ``` @@ -259,7 +259,7 @@ export const CounterStore = signalStore( @Component({...}) class CounterMutation { // ... - private saveToServer = httpMutation({ + private saveToServer = httpMutation({ // ... onError: (error) => { console.error('Failed to send counter:', error); @@ -281,7 +281,7 @@ class CounterMutation { }), })), class SomeComponent { - private saveToServer = httpMutation({ + private saveToServer = httpMutation({ // ... // Passing in a custom option. Need to import like: // `import { switchOp } from '@angular-architects/ngrx-toolkit'` @@ -376,13 +376,14 @@ export const CounterStore = signalStore( console.error('Error occurred:', error); }, }), - saveToServer: httpMutation({ - request: () => ({ + saveToServer: httpMutation({ + request: (_: void) => ({ url: `https://httpbin.org/post`, method: 'POST', body: { counter: store.counter() }, headers: { 'Content-Type': 'application/json' }, }), + parse: (res) => res as CounterResponse, onSuccess: (response) => { console.log('Counter sent to server:', response); patchState(store, { lastResponse: response.json }); From 03290b14a403b9bbf78a2b49336760f3d71a11c4 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:00:41 -0500 Subject: [PATCH 02/12] docs(mutation): specify typing of value with `parse` --- docs/docs/mutations.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index bebf329..95de50d 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -33,6 +33,7 @@ This guide covers - Calling the mutations (optionally as promises) - State signals available (`value/status/error/isPending`) + - For `httpMutation`, the response type is specified with the param `parse: (res: T) => res as T` - `hasValue` signal to narrow type. NOTE: currently there is an outstanding bug that this does not properly narrow. - [How to use](#usage-withmutations-or-solo-functions), as: - _standalone functions_ @@ -213,6 +214,7 @@ Each mutation has the following: - Passing params via RxJS or RxJS-less `HttpClient` signature - See ["Choosing between `rxMutation` and `httpMutation`"](#choosing-between-rxmutation-and-httpmutation) - State signals: `value/status/error/isPending/status/hasValue` + - For `httpMutation`, the response type is specified with the param `parse: (res: T) => res as T` - (optional, but has default) Flattening operators - (optional) callbacks: `onSuccess` and `onError` - Exposes a method of the same name as the mutation, which is a promise. From bd0eb167ff55aa828273fd4b22601bdb1f5c5fdf Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:02:47 -0500 Subject: [PATCH 03/12] docs(mutation): capitalize `Promise` --- docs/docs/mutations.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 95de50d..e0a40e7 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -30,7 +30,7 @@ This guide covers - The params to pass (via RxJS or via `HttpClient` params without RxJS) - Callbacks available (`onSuccess` and `onError`) - Flattening operators (`concatOp, exhaustOp, mergeOp, switchOp`) - - Calling the mutations (optionally as promises) + - Calling the mutations (optionally as `Promise`) - State signals available (`value/status/error/isPending`) - For `httpMutation`, the response type is specified with the param `parse: (res: T) => res as T` @@ -70,7 +70,7 @@ Each mutation has the following: 1. Parameters to pass to an RxJS stream (`rxMutation`) or RxJS agnostic `HttpClient` call (`httpMutation`) 1. Callbacks: `onSuccess` and `onError` (optional) 1. Flattening operators (optional, defaults to `concatOp`) -1. Exposes a method of the same name as the mutation, returns a promise. +1. Exposes a method of the same name as the mutation, returns a `Promise`. 1. State signals: `value/status/error/isPending/hasValue` Additionally, mutations can be used in either `withMutations()` or as standalone functions. @@ -149,14 +149,14 @@ saveToServer: httpMutation({ ### Methods -Enables the method (returns a promise) +Enables the method (returns a `Promise`) ```ts // Call directly store.increment({...}); mutationName.saveToServer({...}); -// or await promises +// or await `Promise`s const inc = await store.increment({...}); if (inc.status === 'success') const save = await store.save({...}); if (inc.status === 'error') ``` @@ -217,7 +217,7 @@ Each mutation has the following: - For `httpMutation`, the response type is specified with the param `parse: (res: T) => res as T` - (optional, but has default) Flattening operators - (optional) callbacks: `onSuccess` and `onError` -- Exposes a method of the same name as the mutation, which is a promise. +- Exposes a method of the same name as the mutation, which is a `Promise`. #### State Signals @@ -294,7 +294,7 @@ class CounterMutation { #### Methods -A mutation is its own function to be invoked, returning a promise should you want to await one. +A mutation is its own function to be invoked, returning a `Promise` should you want to await one. ```ts @Component({...}) @@ -426,7 +426,7 @@ export class CounterMutation { this.store.increment({ value: 1 }); } - // promise version nice if you want to the result's `status` + // `Promise` version nice if you want to the result's `status` async saveToServer() { await this.store.saveToServer(); } From 635c8c84f261f2111998bbe4c1e13a7a745c8a33 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:10:55 -0500 Subject: [PATCH 04/12] docs(mutation): mention why `Promise` for methods --- docs/docs/mutations.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index e0a40e7..6dc6a61 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -320,6 +320,16 @@ class CounterRxMutation { } ``` +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 @@ -331,7 +341,7 @@ For brevity, take `rx` as `rxMutation` and `http` for `httpMutation` - `rx` could be any valid observable, even if it is not HTTP related. - `http` has to be an HTTP request. The user's API is agnostic of RxJS. _Technically, HttpClient with observables is used under the hood_. - Primary property to pass parameters to: - - `rx`'s `operation` is a function that defines the mutation logic. It returns an Observable, + - `rx`'s `operation` is a function that defines the mutation logic. It returns an `Observable`, - `http` takes parts of `HttpClient`'s method signature, or a `request` object which accepts those parts From 65c43885a0c8bef45041dd2069b292cd044b8649 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:12:23 -0500 Subject: [PATCH 05/12] docs(mutation): `signalStore`/SignalStore --- docs/docs/mutations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 6dc6a61..edf3014 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -59,7 +59,7 @@ for example, HTTP methods like POST/PUT/DELETE.** Though other HTTP methods are ### Path the toolkit is following for Mutations -Libraries like Angular Query offer a [Mutation API](https://tanstack.com/query/latest/docs/framework/angular/guides/mutations) for such cases. Some time ago, Marko Stanimirović also [proposed a Mutation API for Angular](https://github.com/markostanimirovic/rx-resource-proto). These mutation functions and features are heavily inspired by Marko's work and adapts it as a custom feature/functions for the NgRx Signal Store. +Libraries like Angular Query offer a [Mutation API](https://tanstack.com/query/latest/docs/framework/angular/guides/mutations) for such cases. Some time ago, Marko Stanimirović also [proposed a Mutation API for Angular](https://github.com/markostanimirovic/rx-resource-proto). These mutation functions and features are heavily inspired by Marko's work and adapts it as a custom feature/functions for the NgRx SignalStore. 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). @@ -177,7 +177,7 @@ mutationName.value; // ^^^ Both of the mutation functions can be used either -- In a signal store, inside of `withMutations()` +- In a `signalStore`, inside of `withMutations()` - On its own, for example, like a class member of a component or service #### Independent of a store From 039d92bb170d2e457a0f0418a9a16e26d1ad35b0 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:13:37 -0500 Subject: [PATCH 06/12] docs(mutation): observable --> `Observable` --- docs/docs/mutations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index edf3014..64e266d 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -338,8 +338,8 @@ similar way as `rxResource` and `httpResource` For brevity, take `rx` as `rxMutation` and `http` for `httpMutation` - `rx` to utilize RxJS streams, `http` to make an `HttpClient` request - - `rx` could be any valid observable, even if it is not HTTP related. - - `http` has to be an HTTP request. The user's API is agnostic of RxJS. _Technically, HttpClient with observables is used under the hood_. + - `rx` could be any valid `Observable`, even if it is not HTTP related. + - `http` has to be an HTTP request. The user's API is agnostic of RxJS. _Technically, HttpClient with `Observable`s is used under the hood_. - Primary property to pass parameters to: - `rx`'s `operation` is a function that defines the mutation logic. It returns an `Observable`, - `http` takes parts of `HttpClient`'s method signature, or a `request` object which accepts those parts From 19479c075720bb50d6b8f4f6de48ede0039d310f Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:24:10 -0500 Subject: [PATCH 07/12] docs(mutations): mention flattening operator nuances --- docs/docs/mutations.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 64e266d..6b424b3 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -136,17 +136,23 @@ Enables handling race conditions increment: rxMutation({ // ... - operator: concatOp, // default if `operator` omitted + // 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({ // ... - // Passing in a custom option. Need to import like: - // `import { switchOp } from '@angular-architects/ngrx-toolkit'` operator: switchOp, }), ``` +:::info +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`) @@ -279,14 +285,14 @@ class CounterMutation { (withMutations((store) => ({ increment: rxMutation({ // ... - operator: concatOp, // default if `operator` omitted + // 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({ // ... - // Passing in a custom option. Need to import like: - // `import { switchOp } from '@angular-architects/ngrx-toolkit'` operator: switchOp, }); }); @@ -379,7 +385,6 @@ export const CounterStore = signalStore( operation: (params: Params) => { return calcSum(store.counter(), params.value); }, - operator: concatOp, onSuccess: (result) => { console.log('result', result); patchState(store, { counter: result }); From 3aea73d7b4e6ce16f445ec9f4fbad44ebefa1e07 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:29:44 -0500 Subject: [PATCH 08/12] docs(mutations): specify use outside of store --- docs/docs/mutations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 6b424b3..7d51bab 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -21,7 +21,7 @@ import { concatOp, exhaustOp, mergeOp, switchOp } from '@angular-architects/ngrx ## 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()` or on their own. +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 From e2583501affad8c11d9ca587e79819d341a7ecf8 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:31:36 -0500 Subject: [PATCH 09/12] docs(mutation): mention discussion w/team --- docs/docs/mutations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 7d51bab..a838c0e 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -59,7 +59,7 @@ for example, HTTP methods like POST/PUT/DELETE.** Though other HTTP methods are ### Path the toolkit is following for Mutations -Libraries like Angular Query offer a [Mutation API](https://tanstack.com/query/latest/docs/framework/angular/guides/mutations) for such cases. Some time ago, Marko Stanimirović also [proposed a Mutation API for Angular](https://github.com/markostanimirovic/rx-resource-proto). These mutation functions and features are heavily inspired by Marko's work and adapts it as a custom feature/functions for the NgRx SignalStore. +Libraries like Angular Query offer a [Mutation API](https://tanstack.com/query/latest/docs/framework/angular/guides/mutations) for such cases. Some time ago, Marko Stanimirović also [proposed a Mutation API for Angular](https://github.com/markostanimirovic/rx-resource-proto). 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). From 7672959355871feb5b370459d3fb0c8548063b0e Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:33:02 -0500 Subject: [PATCH 10/12] docs(mutation): exposes --> factory function --- docs/docs/mutations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index a838c0e..2c00002 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -70,7 +70,7 @@ Each mutation has the following: 1. Parameters to pass to an RxJS stream (`rxMutation`) or RxJS agnostic `HttpClient` call (`httpMutation`) 1. Callbacks: `onSuccess` and `onError` (optional) 1. Flattening operators (optional, defaults to `concatOp`) -1. Exposes a method of the same name as the mutation, returns a `Promise`. +1. Provides a factory function of the same name as the mutation, returns a `Promise`. 1. State signals: `value/status/error/isPending/hasValue` Additionally, mutations can be used in either `withMutations()` or as standalone functions. @@ -223,7 +223,7 @@ Each mutation has the following: - For `httpMutation`, the response type is specified with the param `parse: (res: T) => res as T` - (optional, but has default) Flattening operators - (optional) callbacks: `onSuccess` and `onError` -- Exposes a method of the same name as the mutation, which is a `Promise`. +- Provides a factory function of the same name as the mutation, returns a `Promise`. #### State Signals From 80e69e9a11782c36606d33b2dc24df9c5b60ea03 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:34:37 -0500 Subject: [PATCH 11/12] docs(mutations): remove explicit json header --- docs/docs/mutations.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 2c00002..ad3713f 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -101,7 +101,6 @@ httpMutation({ url: `https://httpbin.org/post`, method: 'POST', body: { counter: p.value }, - headers: { 'Content-Type': 'application/json' }, }) ); ``` @@ -398,7 +397,6 @@ export const CounterStore = signalStore( url: `https://httpbin.org/post`, method: 'POST', body: { counter: store.counter() }, - headers: { 'Content-Type': 'application/json' }, }), parse: (res) => res as CounterResponse, onSuccess: (response) => { From b7408f517a1c3c5b42304719845b74a9d0f5ef7a Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:45:21 -0500 Subject: [PATCH 12/12] docs(mutation): show parse/callback inference --- docs/docs/mutations.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index ad3713f..c48b46b 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -101,7 +101,8 @@ httpMutation({ url: `https://httpbin.org/post`, method: 'POST', body: { counter: p.value }, - }) + }), + parse: (res) => res as CounterResponse, ); ``` @@ -111,7 +112,7 @@ In the mutation: _optional_ `onSuccess` and `onError` callbacks ```ts ({ - onSuccess: (result) => { + onSuccess: (result: CounterResponse) => { // optional // method: // this.counterSignal.set(result); @@ -255,7 +256,7 @@ export const CounterStore = signalStore( withMutations((store) => ({ increment: rxMutation({ // ... - onSuccess: (result) => { + onSuccess: (result: CounterResponse) => { console.log('result', result); patchState(store, { counter: result }); }, @@ -384,7 +385,7 @@ export const CounterStore = signalStore( operation: (params: Params) => { return calcSum(store.counter(), params.value); }, - onSuccess: (result) => { + onSuccess: (result: number) => { console.log('result', result); patchState(store, { counter: result }); }, @@ -399,7 +400,7 @@ export const CounterStore = signalStore( body: { counter: store.counter() }, }), parse: (res) => res as CounterResponse, - onSuccess: (response) => { + onSuccess: (response) => { // response inferred as per `parse` ^^^ console.log('Counter sent to server:', response); patchState(store, { lastResponse: response.json }); },