From 5c3fb10c756db105b174eb960dbd3abaca7f903a Mon Sep 17 00:00:00 2001 From: michael-small Date: Fri, 5 Sep 2025 04:14:58 +0000 Subject: [PATCH 01/36] docs(mutations): create draft of mutations --- docs/docs/mutations.md | 25 +++++++++++++++++++++++++ docs/sidebars.ts | 1 + 2 files changed, 26 insertions(+) create mode 100644 docs/docs/mutations.md diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md new file mode 100644 index 0000000..a089c0e --- /dev/null +++ b/docs/docs/mutations.md @@ -0,0 +1,25 @@ +--- +title: Mutations +--- + +```typescript +import { httpMutation } from '@angular-architects/ngrx-toolkit'; +``` + +```typescript +import { rxMutation } from '@angular-architects/ngrx-toolkit'; +``` + +```typescript +import { withMutations } from '@angular-architects/ngrx-toolkit'; +``` + +## Outline (WIP) + +1. Context for mutations + - Resources GET ONLY + - Angular Query --> Marko's draft --> toolkit + - TODO links: query, Marko's draft, toolkit RFC, resources talk on mutations? +1. Actual `httpMutation/rxMutation` standalones + feature + - Bundled by rx and non-rx? Or functions then the feature? + - Examples diff --git a/docs/sidebars.ts b/docs/sidebars.ts index bd037ed..a1dc30b 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -27,6 +27,7 @@ const sidebars: SidebarsConfig = { 'with-storage-sync', 'with-undo-redo', 'with-redux', + 'mutations', ], reduxConnectorSidebar: [ { From ee60344be10349688c6c5f21e83ab50963dc9b02 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 6 Sep 2025 15:02:26 -0500 Subject: [PATCH 02/36] docs(mutations): write preface ("why" & "who") --- .../counter-rx-mutation.ts | 3 + docs/docs/mutations.md | 78 +++++++++++++++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts index 631a086..ca3d472 100644 --- a/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts +++ b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts @@ -16,6 +16,9 @@ export type CounterResponse = { json: { counter: number }; }; +// TODO - rename this file to just be `mutations-functions-standalone` + class/selector etc?? +// And then the other folder to "store" +// Or maybe put these all in one folder too while we are at it? @Component({ selector: 'demo-counter-rx-mutation', imports: [CommonModule], diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index a089c0e..4c1b9ec 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -14,12 +14,80 @@ import { rxMutation } from '@angular-architects/ngrx-toolkit'; import { withMutations } from '@angular-architects/ngrx-toolkit'; ``` -## Outline (WIP) +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. + +In `withMutations()` + +```ts + // functions defined below + withMutations((store) => ({ + increment: rxMutation({...}), + saveToServer: httpMutation({...}), + })), +``` + +Function examples, such as a component or service: + +```ts + // function calcSum(a: number, b: number): Observable {...} + + private increment = rxMutation({ + operation: (params: Params) => { + return calcSum(this.counterSignal(), params.value); + }, + operator: concatOp, + onSuccess: (result) => { + this.counterSignal.set(result); + }, + onError: (error) => { + console.error('Error occurred:', error); + }, + }); + + private saveToServer = httpMutation({ + request: (p) => ({ + url: `https://httpbin.org/post`, + method: 'POST', + body: { counter: p.value }, + headers: { 'Content-Type': 'application/json' }, + }), + onSuccess: (response) => { + console.log('Counter sent to server:', response); + }, + onError: (error) => { + console.error('Failed to send counter:', error); + }, + }); +``` + +This guide covers + +- Why we do not use `withResource`, and the direction on mutations from the community +- `withMutations` store _feature_, and the usage of `httpMutation` and `rxMutation` inside of the feature +- `httpMutation` and `rxMutation` as standalone _functions_ that can be used outside of a store + +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](https://github.com/angular/angular/discussions/60120)) and (#2, [APIs](https://github.com/angular/angular/discussions/60121)), 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.** + +> "`httpResource` (and the more fundamental `resource`) 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](https://github.com/angular/angular/discussions/60121) + +### What lead the ngrx-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). This RFC is heavily inspired by Marko's work and adapts it as a custom feature for the NgRx Signal Store. + +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). + +## TODO - actual API examples + definitons -1. Context for mutations - - Resources GET ONLY - - Angular Query --> Marko's draft --> toolkit - - TODO links: query, Marko's draft, toolkit RFC, resources talk on mutations? 1. Actual `httpMutation/rxMutation` standalones + feature - Bundled by rx and non-rx? Or functions then the feature? - Examples From 29d1d565375f5bf207f41b5776fb7229d907db8b Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 6 Sep 2025 17:46:45 -0500 Subject: [PATCH 03/36] docs(mutations): provide examples at top --- docs/docs/mutations.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 4c1b9ec..6dd3e1e 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -24,6 +24,13 @@ In `withMutations()` increment: rxMutation({...}), saveToServer: httpMutation({...}), })), + + // Enables the method (returns a promise) + store.increment({...}) + store.saveToServer({...}) + + // Enables the following signal states + store.increment.(value/status/error/isPending/status/hasValue); ``` Function examples, such as a component or service: From d55c4404ee24ba884ffd1a11a4f7a8b0cd7ac647 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 6 Sep 2025 18:05:51 -0500 Subject: [PATCH 04/36] docs(mutations): give example snippet + WIP usage --- docs/docs/mutations.md | 49 ++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 6dd3e1e..2b17e58 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -16,24 +16,30 @@ import { withMutations } from '@angular-architects/ngrx-toolkit'; 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. -In `withMutations()` +Mutations enable the use of the following: ```ts - // functions defined below +// 1. In the mutation:`onSuccess` and `onError` callbacks + +// 2. Enables the method (returns a promise) +store.increment({...}) +store.saveToServer({...}) + +// 3. Enables the following signal states +store.increment.(value/status/error/isPending/status/hasValue); +``` + +Usage in `withMutations()` + +```ts + // functions defined in next block withMutations((store) => ({ increment: rxMutation({...}), saveToServer: httpMutation({...}), })), - - // Enables the method (returns a promise) - store.increment({...}) - store.saveToServer({...}) - - // Enables the following signal states - store.increment.(value/status/error/isPending/status/hasValue); ``` -Function examples, such as a component or service: +Usage as functions, such as a component or service: ```ts // function calcSum(a: number, b: number): Observable {...} @@ -43,10 +49,10 @@ Function examples, such as a component or service: return calcSum(this.counterSignal(), params.value); }, operator: concatOp, - onSuccess: (result) => { + onSuccess: (result) => { // optional this.counterSignal.set(result); }, - onError: (error) => { + onError: (error) => { // optional console.error('Error occurred:', error); }, }); @@ -58,10 +64,10 @@ Function examples, such as a component or service: body: { counter: p.value }, headers: { 'Content-Type': 'application/json' }, }), - onSuccess: (response) => { + onSuccess: (response) => { // optional console.log('Counter sent to server:', response); }, - onError: (error) => { + onError: (error) => { // optional console.error('Failed to send counter:', error); }, }); @@ -93,8 +99,15 @@ Libraries like Angular Query offer a [Mutation API](https://tanstack.com/query/l 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). -## TODO - actual API examples + definitons +## Basic Usage + +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. + +Each mutation has the following: + +- State signals: value/status/error/isPending/status/hasValue +- (optional) callbacks, `onSuccess` and `onError` + +### Inside `withMutations()` -1. Actual `httpMutation/rxMutation` standalones + feature - - Bundled by rx and non-rx? Or functions then the feature? - - Examples +### Independent of a store From 98e15ce63aade8e621542cb3f2f1bc8d319d8010 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 6 Sep 2025 19:11:22 -0500 Subject: [PATCH 05/36] docs(mutations): list differences between `rx` and `http` --- docs/docs/mutations.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 2b17e58..b9d7359 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -14,6 +14,8 @@ import { rxMutation } from '@angular-architects/ngrx-toolkit'; import { withMutations } from '@angular-architects/ngrx-toolkit'; ``` +## Basic Usage - Summary + 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. Mutations enable the use of the following: @@ -99,14 +101,32 @@ Libraries like Angular Query offer a [Mutation API](https://tanstack.com/query/l 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). -## Basic Usage +## Basic 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. Each mutation has the following: -- State signals: value/status/error/isPending/status/hasValue +- State signals: `value/status/error/isPending/status/hasValue` - (optional) callbacks, `onSuccess` and `onError` +- Exposes a method of the same name as the mutation, which is a promise. + +### 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 an `HttpClient` request agnostic of RxJS (at the user's API surface) +- 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 +- Race condition handling + - `rx` takes optional wrapper of an RxJS flattening operator. + - By default `concat` (`concatMap`) sematics are used + - Optionally can be passed a `switchOp (switchMap)`, `mergeOp (mergeMap)`, `concatOp (concatMap)`, and `exhauseOp (exhaustMap)` + - `http` does not automatically prevent race conditions using a flattening operator. The caller is responsible for handling concurrency, e.g., by disabling buttons during processing ### Inside `withMutations()` From 0e216dada8b09612eedac7324f3d619451f87a7b Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 6 Sep 2025 20:10:30 -0500 Subject: [PATCH 06/36] docs(mutations): write in depth features (state/callbacks/methods) --- docs/docs/mutations.md | 120 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 113 insertions(+), 7 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index b9d7359..0f4da0e 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -22,13 +22,23 @@ Mutations enable the use of the following: ```ts // 1. In the mutation:`onSuccess` and `onError` callbacks +({ + onSuccess: (result) => { // optional + // method: this.counterSignal.set(result); + // store: patchState(store, {counter: result}); + }, + onError: (error) => { // optional + console.error('Error occurred:', error); + }, +}) // 2. Enables the method (returns a promise) store.increment({...}) -store.saveToServer({...}) +mutationName.saveToServer({...}) // 3. Enables the following signal states -store.increment.(value/status/error/isPending/status/hasValue); +store.increment.value; // also status/error/isPending/status/hasValue; +mutationName.value; // ^^^ ``` Usage in `withMutations()` @@ -105,12 +115,112 @@ The goal is to provide a simple Mutation API that is available now for early ado 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 + Each mutation has the following: - State signals: `value/status/error/isPending/status/hasValue` -- (optional) callbacks, `onSuccess` and `onError` +- (optional) callbacks: `onSuccess` and `onError` - Exposes a method of the same name as the mutation, which is a promise. +#### State Signals + +```ts +// Accessed from store or variable +storeName.mutationName.value; // or other signals +mutationName.value; // ^^^ + +// With the following types: +export type MutationStatus = 'idle' | 'pending' | 'error' | 'success'; + +export type Mutation = { + status: Signal<'idle' | 'pending' | 'error' | 'success'>; + value: Signal; + isPending: Signal; + isSuccess: Signal; + error: Signal; + hasValue(): this is Mutation, Result>; // type narrows `.value()` +}; +``` + +#### (optional) Callbacks: `onSuccess` and `onError` + +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. + +To shake up the examples, lets define an `onSuccess` in a `withMutations()` using store and an `onError` in a mutation which is a member of a component. + +```ts +export const CounterStore = signalStore( + // ... + withMutations((store) => ({ + increment: rxMutation({ + // ... + onSuccess: (result) => { + console.log('result', result); + patchState(store, { counter: result }); + }, + }), + })), +); + +@Component({...}) +class CounterRxMutation { + // ... + private saveToServer = httpMutation({ + onSuccess: (response) => { + console.log('Counter sent to server:', response); + }, + onError: (error) => { + console.error('Failed to send counter:', error); + }, + }); +} +``` + +#### Methods + +A mutation is its own function to be invoked, returning a promise should you want to await one. + +```ts +@Component({...}) +class CounterRxMutation { + // From mutation methods in a class + private increment = rxMutation({...}); + // + // await or not + async incrementBy13() { + const result = await this.increment({ value: 13 }); + if (result.status === 'success') { ... } + } + incrementBy12() { + this.increment({ value: 12 }); + } + + // From mutation in `withMutations()` + private store = inject(CounterStore); + // + // await or not + async incrementBy13() { + const result = await this.store.increment({ value: 13 }); + if (result.status === 'success') { ... } + } + incrementBy12() { + this.store.increment({ value: 12 }); + } +} +``` + +### Usage: in `withMutations()`, or outside of it + +#### Independent of a store + +`rxMutation` and `httpMutation` are functions that can be used outside of a store, just as naturally as within a store. Including but not limited to: + +- + +#### Inside `withMutations()` + ### Choosing between `rxMutation` and `httpMutation` Though mutations and resources have different intents, the difference between `rxMutation` and `httpMutation` can be seen in a @@ -127,7 +237,3 @@ For brevity, take `rx` as `rxMutation` and `http` for `httpMutation` - By default `concat` (`concatMap`) sematics are used - Optionally can be passed a `switchOp (switchMap)`, `mergeOp (mergeMap)`, `concatOp (concatMap)`, and `exhauseOp (exhaustMap)` - `http` does not automatically prevent race conditions using a flattening operator. The caller is responsible for handling concurrency, e.g., by disabling buttons during processing - -### Inside `withMutations()` - -### Independent of a store From 6c54c310c4fadfe696e4d00d960bf5e11a6b7eaf Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 6 Sep 2025 20:41:24 -0500 Subject: [PATCH 07/36] docs(mutations): show in vs out of store use --- docs/docs/mutations.md | 59 ++++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 0f4da0e..f814ce2 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -165,7 +165,7 @@ export const CounterStore = signalStore( ); @Component({...}) -class CounterRxMutation { +class CounterMutation { // ... private saveToServer = httpMutation({ onSuccess: (response) => { @@ -185,42 +185,63 @@ A mutation is its own function to be invoked, returning a promise should you wan ```ts @Component({...}) class CounterRxMutation { - // From mutation methods in a class private increment = rxMutation({...}); - // + private store = inject(CounterStore); + // await or not async incrementBy13() { - const result = await this.increment({ value: 13 }); - if (result.status === 'success') { ... } + const resultA = await this.increment({ value: 13 }); + if (resultA.status === 'success') { ... } + + const resultB = await this.store.increment({ value: 13 }); + if (resultB.status === 'success') { ... } } + incrementBy12() { this.increment({ value: 12 }); - } - // From mutation in `withMutations()` - private store = inject(CounterStore); - // - // await or not - async incrementBy13() { - const result = await this.store.increment({ value: 13 }); - if (result.status === 'success') { ... } - } - incrementBy12() { this.store.increment({ value: 12 }); } -} ``` + + ### Usage: in `withMutations()`, or outside of it -#### Independent of a store +Both of the mutation functions can be used either -`rxMutation` and `httpMutation` are functions that can be used outside of a store, just as naturally as within a store. Including but not limited to: +- In a signal store, inside of `withMutations()` +- On its own, for example, like a class member of a component or service -- +#### Independent of a store + +```ts +@Component({...}) +class CounterMutation { + private increment = rxMutation({...}); + private saveToServer = httpMutation({...}); +} +``` #### Inside `withMutations()` +```ts +export const CounterStore = signalStore( + // ... + withMutations((store) => ({ + // the same functions + increment: rxMutation({...}), + saveToServer: httpMutation({...}), + })), +); +``` + ### Choosing between `rxMutation` and `httpMutation` Though mutations and resources have different intents, the difference between `rxMutation` and `httpMutation` can be seen in a From 3177b29c00ec9b475eb469fc8484ab86b3fab09b Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 6 Sep 2025 21:27:05 -0500 Subject: [PATCH 08/36] docs(mutations): re-order + flesh out misc points --- docs/docs/mutations.md | 184 ++++++++++++++++++++--------------------- 1 file changed, 88 insertions(+), 96 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index f814ce2..ef28c2e 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -18,81 +18,108 @@ import { withMutations } from '@angular-architects/ngrx-toolkit'; 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. -Mutations enable the use of the following: +This guide covers + +- Key Features: + - The params to pass + - Callbacks available + - Calling the mutations (optionally as promises) + - State signals available +- Why we do not use `withResource`, and the direction on mutations from the community +- `httpMutation` and `rxMutation` as standalone _functions_ that can be used outside of a store +- `withMutations` store _feature_, and the usage of `httpMutation` and `rxMutation` functions inside the feature + +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. + +### Key features + +Each mutation has the following: + +1. Parameters to pass to an RxJS stream (`rxMutation`) or RxJS agnostic `HttpClient` call (`httpMutation`) +1. (optional) callbacks: `onSuccess` and `onError` +1. Exposes a method of the same name as the mutation, returns a promise. +1. State signals: `value/status/error/isPending/status/hasValue` ```ts -// 1. In the mutation:`onSuccess` and `onError` callbacks +// 1. Params + call + +// RxJS stream +rxMutation({ + operation: (params: Params) => { + // function calcSum(a: number, b: number): Observable + return calcSum(this.counterSignal(), params.value); + }, +}) + +// http call, as options +httpMutation((userData) => ({ + url: '/api/users', + method: 'POST', + body: userData, +})), +// OR +// http call, as function + options +httpMutation({ + request: (p) => ({ + url: `https://httpbin.org/post`, + method: 'POST', + body: { counter: p.value }, + headers: { 'Content-Type': 'application/json' }, + }) +); + +// 2. In the mutation:`onSuccess` and `onError` callbacks ({ onSuccess: (result) => { // optional - // method: this.counterSignal.set(result); - // store: patchState(store, {counter: result}); + // method: + // this.counterSignal.set(result); + // store: + // patchState(store, {counter: result}); }, onError: (error) => { // optional console.error('Error occurred:', error); }, }) -// 2. Enables the method (returns a promise) -store.increment({...}) -mutationName.saveToServer({...}) +// 3. Enables the method (returns a promise) +store.increment({...}); // const inc = await store.increment; if (inc.status === 'success') +mutationName.saveToServer({...}); // const save = await store.save; if (inc.status === 'error') -// 3. Enables the following signal states +// 4. Enables the following signal states store.increment.value; // also status/error/isPending/status/hasValue; mutationName.value; // ^^^ ``` -Usage in `withMutations()` +### Usage: `withMutations()` or solo functions -```ts - // functions defined in next block - withMutations((store) => ({ - increment: rxMutation({...}), - saveToServer: httpMutation({...}), - })), -``` - -Usage as functions, such as a component or service: +Both of the mutation functions can be used either -```ts - // function calcSum(a: number, b: number): Observable {...} +- In a signal store, inside of `withMutations()` +- On its own, for example, like a class member of a component or service - private increment = rxMutation({ - operation: (params: Params) => { - return calcSum(this.counterSignal(), params.value); - }, - operator: concatOp, - onSuccess: (result) => { // optional - this.counterSignal.set(result); - }, - onError: (error) => { // optional - console.error('Error occurred:', error); - }, - }); +#### Independent of a store - private saveToServer = httpMutation({ - request: (p) => ({ - url: `https://httpbin.org/post`, - method: 'POST', - body: { counter: p.value }, - headers: { 'Content-Type': 'application/json' }, - }), - onSuccess: (response) => { // optional - console.log('Counter sent to server:', response); - }, - onError: (error) => { // optional - console.error('Failed to send counter:', error); - }, - }); +```ts +@Component({...}) +class CounterMutation { + private increment = rxMutation({...}); + private saveToServer = httpMutation({...}); +} ``` -This guide covers - -- Why we do not use `withResource`, and the direction on mutations from the community -- `withMutations` store _feature_, and the usage of `httpMutation` and `rxMutation` inside of the feature -- `httpMutation` and `rxMutation` as standalone _functions_ that can be used outside of a store +#### Inside `withMutations()` -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. +```ts +export const CounterStore = signalStore( + // ... + withMutations((store) => ({ + // the same functions + increment: rxMutation({...}), + saveToServer: httpMutation({...}), + })), +); +``` ## Background @@ -105,7 +132,7 @@ for example, HTTP methods like POST/PUT/DELETE.** > "`httpResource` (and the more fundamental `resource`) 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](https://github.com/angular/angular/discussions/60121) -### What lead the ngrx-toolkit is following for Mutations +### 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). This RFC is heavily inspired by Marko's work and adapts it as a custom feature for the NgRx Signal Store. @@ -119,6 +146,8 @@ The mutation functions can be used in a `withMutations()` feature, but can be us Each mutation has the following: + + - State signals: `value/status/error/isPending/status/hasValue` - (optional) callbacks: `onSuccess` and `onError` - Exposes a method of the same name as the mutation, which is a promise. @@ -126,11 +155,7 @@ Each mutation has the following: #### State Signals ```ts -// Accessed from store or variable -storeName.mutationName.value; // or other signals -mutationName.value; // ^^^ - -// With the following types: +// Fields + types types: export type MutationStatus = 'idle' | 'pending' | 'error' | 'success'; export type Mutation = { @@ -141,6 +166,10 @@ export type Mutation = { error: Signal; hasValue(): this is Mutation, Result>; // type narrows `.value()` }; + +// Accessed from store or variable +storeName.mutationName.value; // or other signals +mutationName.value; // ^^^ ``` #### (optional) Callbacks: `onSuccess` and `onError` @@ -202,46 +231,9 @@ class CounterRxMutation { this.store.increment({ value: 12 }); } -``` - - - -### Usage: in `withMutations()`, or outside of it - -Both of the mutation functions can be used either - -- In a signal store, inside of `withMutations()` -- On its own, for example, like a class member of a component or service - -#### Independent of a store - -```ts -@Component({...}) -class CounterMutation { - private increment = rxMutation({...}); - private saveToServer = httpMutation({...}); } ``` -#### Inside `withMutations()` - -```ts -export const CounterStore = signalStore( - // ... - withMutations((store) => ({ - // the same functions - increment: rxMutation({...}), - saveToServer: httpMutation({...}), - })), -); -``` - ### Choosing between `rxMutation` and `httpMutation` Though mutations and resources have different intents, the difference between `rxMutation` and `httpMutation` can be seen in a From c5fe6371bbedeb494902da205ae44c596b88c514 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 10:47:34 -0500 Subject: [PATCH 09/36] docs(mutations): add imports for mutations flatteners --- docs/docs/mutations.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index ef28c2e..ff55945 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -8,6 +8,9 @@ import { httpMutation } from '@angular-architects/ngrx-toolkit'; ```typescript import { rxMutation } from '@angular-architects/ngrx-toolkit'; + +// Optional, `concatOp` is the default +import { concatOp, exhaustOp, mergeOp, switchOp } from '@angular-architects/ngrx-toolkit'; ``` ```typescript From 0d7fa1cdd459f1ea7f711d260561a75ea34ee399 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 10:58:57 -0500 Subject: [PATCH 10/36] docs(mutations): re-order examples + background --- docs/docs/mutations.md | 85 ++++++++++++++++++++++++++++-------------- 1 file changed, 56 insertions(+), 29 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index ff55945..e286a0e 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -23,19 +23,36 @@ The mutations feature (`withMutations`) and methods (`httpMutation` and `rxMutat This guide covers +- Why we do not use `withResource`, and the direction on mutations from the community - Key Features: - - The params to pass - - Callbacks available + - The params to pass (via RxJS or via `HttpClient` params without RxJS) + - Callbacks available (`onSuccess` and `onError`) - Calling the mutations (optionally as promises) - - State signals available -- Why we do not use `withResource`, and the direction on mutations from the community + - State signals available (`value/status/error/isPending/status/hasValue`) - `httpMutation` and `rxMutation` as standalone _functions_ that can be used outside of a store - `withMutations` store _feature_, and the usage of `httpMutation` and `rxMutation` functions inside the feature 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. -### Key features +## 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](https://github.com/angular/angular/discussions/60120)) and (#2, [APIs](https://github.com/angular/angular/discussions/60121)), 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.** + +> "`httpResource` (and the more fundamental `resource`) 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](https://github.com/angular/angular/discussions/60121) + +### 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). This RFC is heavily inspired by Marko's work and adapts it as a custom feature for the NgRx Signal Store. + +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 Each mutation has the following: @@ -44,6 +61,8 @@ Each mutation has the following: 1. Exposes a method of the same name as the mutation, returns a promise. 1. State signals: `value/status/error/isPending/status/hasValue` +### Params + ```ts // 1. Params + call @@ -71,26 +90,50 @@ httpMutation({ headers: { 'Content-Type': 'application/json' }, }) ); +``` + +### Callbacks -// 2. In the mutation:`onSuccess` and `onError` callbacks +```ts +// 2. In the mutation: *optional* `onSuccess` and `onError` callbacks ({ - onSuccess: (result) => { // optional + onSuccess: (result) => { + // optional // method: // this.counterSignal.set(result); // store: // patchState(store, {counter: result}); }, - onError: (error) => { // optional + onError: (error) => { + // optional console.error('Error occurred:', error); }, -}) +}); +``` +### Methods + +```ts // 3. Enables the method (returns a promise) -store.increment({...}); // const inc = await store.increment; if (inc.status === 'success') -mutationName.saveToServer({...}); // const save = await store.save; if (inc.status === 'error') +// Call directly +store.increment({...}); +mutationName.saveToServer({...}); + +// or await promises +const inc = await store.increment({...}); if (inc.status === 'success') +const save = await store.save({...}); if (inc.status === 'error') +``` + +### Signal values + +```ts // 4. Enables the following signal states + +// via store store.increment.value; // also status/error/isPending/status/hasValue; + +// via member variable mutationName.value; // ^^^ ``` @@ -124,24 +167,7 @@ export const CounterStore = signalStore( ); ``` -## 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](https://github.com/angular/angular/discussions/60120)) and (#2, [APIs](https://github.com/angular/angular/discussions/60121)), 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.** - -> "`httpResource` (and the more fundamental `resource`) 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](https://github.com/angular/angular/discussions/60121) - -### 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). This RFC is heavily inspired by Marko's work and adapts it as a custom feature for the NgRx Signal Store. - -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). - -## Basic Usage - In Depth +## 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. @@ -151,6 +177,7 @@ Each mutation has the following: +- Passing params via RxJS or RxJS-less `HttpClient` signature - see last section on difference - State signals: `value/status/error/isPending/status/hasValue` - (optional) callbacks: `onSuccess` and `onError` - Exposes a method of the same name as the mutation, which is a promise. From 8d1e242c8b6903aff92729c15d0b63d2a617b031 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 11:02:21 -0500 Subject: [PATCH 11/36] docs(mutations): remove extra callback example --- docs/docs/mutations.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index e286a0e..fe3377b 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -227,9 +227,7 @@ export const CounterStore = signalStore( class CounterMutation { // ... private saveToServer = httpMutation({ - onSuccess: (response) => { - console.log('Counter sent to server:', response); - }, + // ... onError: (error) => { console.error('Failed to send counter:', error); }, From 3420f9d14a9843e1c71000c3156ba8d3c5a983e6 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 11:21:10 -0500 Subject: [PATCH 12/36] docs(mutations): remove our RFC copypasta + link to `withResource` --- docs/docs/mutations.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index fe3377b..510e077 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -23,12 +23,12 @@ The mutations feature (`withMutations`) and methods (`httpMutation` and `rxMutat This guide covers -- Why we do not use `withResource`, and the direction on mutations from the community +- Why we do not use [`withResource`](./with-resource), and the direction on mutations from the community - Key Features: - The params to pass (via RxJS or via `HttpClient` params without RxJS) - Callbacks available (`onSuccess` and `onError`) - Calling the mutations (optionally as promises) - - State signals available (`value/status/error/isPending/status/hasValue`) + - State signals available (`value/status/error/isPending/hasValue`) - `httpMutation` and `rxMutation` as standalone _functions_ that can be used outside of a store - `withMutations` store _feature_, and the usage of `httpMutation` and `rxMutation` functions inside the feature @@ -37,7 +37,7 @@ the "Why" and "Who" of why mutations were built for the toolkit like this. ## Background -### Why not handle mutations using `withResource`? +### Why not handle mutations using [`withResource`](./with-resource)? 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](https://github.com/angular/angular/discussions/60120)) and (#2, [APIs](https://github.com/angular/angular/discussions/60121)), and followup @@ -48,7 +48,7 @@ for example, HTTP methods like POST/PUT/DELETE.** ### 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). This RFC is heavily inspired by Marko's work and adapts it as a custom feature 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 Signal Store. 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). @@ -59,7 +59,7 @@ Each mutation has the following: 1. Parameters to pass to an RxJS stream (`rxMutation`) or RxJS agnostic `HttpClient` call (`httpMutation`) 1. (optional) callbacks: `onSuccess` and `onError` 1. Exposes a method of the same name as the mutation, returns a promise. -1. State signals: `value/status/error/isPending/status/hasValue` +1. State signals: `value/status/error/isPending/hasValue` ### Params From aac5421489d0548fe7d147f0286f7ac57399187f Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 11:32:18 -0500 Subject: [PATCH 13/36] docs(mutations): add full example --- docs/docs/mutations.md | 99 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 510e077..282d625 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -278,3 +278,102 @@ For brevity, take `rx` as `rxMutation` and `http` for `httpMutation` - By default `concat` (`concatMap`) sematics are used - Optionally can be passed a `switchOp (switchMap)`, `mergeOp (mergeMap)`, `concatOp (concatMap)`, and `exhauseOp (exhaustMap)` - `http` does not automatically prevent race conditions using a flattening operator. The caller is responsible for handling concurrency, e.g., by disabling buttons during processing + +## 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 and used in a component, but they could be class members of a service/component or `const`s for example/ + +### Declare mutations (ex - store) + +```ts +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); + }, + operator: concatOp, + onSuccess: (result) => { + console.log('result', result); + patchState(store, { counter: result }); + }, + onError: (error) => { + console.error('Error occurred:', error); + }, + }), + saveToServer: httpMutation({ + request: () => ({ + url: `https://httpbin.org/post`, + method: 'POST', + body: { counter: store.counter() }, + headers: { 'Content-Type': 'application/json' }, + }), + onSuccess: (response) => { + console.log('Counter sent to server:', response); + patchState(store, { lastResponse: response.json }); + }, + onError: (error) => { + console.error('Failed to send counter:', error); + }, + }), + })), +); + +function createSumObservable(a: number, b: number): Observable { + // ... +} + +function calcSum(a: number, b: number): Observable { + // return of(a + b); + return createSumObservable(a, b).pipe(delay(500)); +} +``` + +### Use (ex - component) + +```ts +@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(); + } +} +``` From 78d102e05dccdf0fb35e91d39f13cd322bf6ec41 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 13:51:49 -0500 Subject: [PATCH 14/36] docs(mutations): misc feedback, emphasis on flattening operators --- docs/docs/mutations.md | 52 +++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 282d625..5bd32d1 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -8,15 +8,17 @@ import { httpMutation } from '@angular-architects/ngrx-toolkit'; ```typescript import { rxMutation } from '@angular-architects/ngrx-toolkit'; - -// Optional, `concatOp` is the default -import { concatOp, exhaustOp, mergeOp, switchOp } from '@angular-architects/ngrx-toolkit'; ``` ```typescript import { withMutations } from '@angular-architects/ngrx-toolkit'; ``` +```typescript +// Optional, `concatOp` is the default. +import { concatOp, exhaustOp, mergeOp, switchOp } from '@angular-architects/ngrx-toolkit'; +``` + ## Basic Usage - Summary 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. @@ -29,14 +31,20 @@ This guide covers - Callbacks available (`onSuccess` and `onError`) - Calling the mutations (optionally as promises) - State signals available (`value/status/error/isPending/hasValue`) + + - Flattening operators - `httpMutation` and `rxMutation` as standalone _functions_ that can be used outside of a store - `withMutations` store _feature_, and the usage of `httpMutation` and `rxMutation` functions inside the feature +- Differences between `httpMutation` and `rxMutation` +- Full example of a usecase of both mutations in a `withMutations()` and then used in a component 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`](./with-resource)? The `resource` API and discussion about it naturally lead to talks about all async operations. @@ -60,6 +68,7 @@ Each mutation has the following: 1. (optional) callbacks: `onSuccess` and `onError` 1. Exposes a method of the same name as the mutation, returns a promise. 1. State signals: `value/status/error/isPending/hasValue` +1. Flattening operators ### Params @@ -137,6 +146,28 @@ store.increment.value; // also status/error/isPending/status/hasValue; mutationName.value; // ^^^ ``` +### Flattening operators + +```ts +// 5. Enables handling race conditions + +// All options: concatOp, exhaustOp, mergeOp, switchOp +// Default: concatOp + + +increment: rxMutation({ + // ... + operator: concatOp, // default if `operator` omitted +}), + +saveToServer: httpMutation({ + // ... + // Passing in custom option. Need to import like: + // `import { switchOp } from '@angular-architects/ngrx-toolkit'` + operator: switchOp, +}), +``` + ### Usage: `withMutations()` or solo functions Both of the mutation functions can be used either @@ -245,7 +276,7 @@ class CounterRxMutation { private increment = rxMutation({...}); private store = inject(CounterStore); - // await or not + // To await async incrementBy13() { const resultA = await this.increment({ value: 13 }); if (resultA.status === 'success') { ... } @@ -254,6 +285,7 @@ class CounterRxMutation { if (resultB.status === 'success') { ... } } + // or not to await, that is the question incrementBy12() { this.increment({ value: 12 }); @@ -269,21 +301,19 @@ 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 agnostic of RxJS (at the user's API surface) +- `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 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, - `http` takes parts of `HttpClient`'s method signature, or a `request` object which accepts those parts -- Race condition handling - - `rx` takes optional wrapper of an RxJS flattening operator. - - By default `concat` (`concatMap`) sematics are used - - Optionally can be passed a `switchOp (switchMap)`, `mergeOp (mergeMap)`, `concatOp (concatMap)`, and `exhauseOp (exhaustMap)` - - `http` does not automatically prevent race conditions using a flattening operator. The caller is responsible for handling concurrency, e.g., by disabling buttons during processing + ## 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 and used in a component, but they could be class members of a service/component or `const`s for example/ +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 mutations (ex - store) From 15118c6587cb8f6d7f0c63bdbad53c737635c4e0 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 15:15:34 -0500 Subject: [PATCH 15/36] docs(mutations): hyperlink to other sections, some ordering --- docs/docs/mutations.md | 128 +++++++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 51 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 5bd32d1..efa47d4 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -19,24 +19,28 @@ import { withMutations } from '@angular-architects/ngrx-toolkit'; import { concatOp, exhaustOp, mergeOp, switchOp } from '@angular-architects/ngrx-toolkit'; ``` -## Basic Usage - Summary +## 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. This guide covers - Why we do not use [`withResource`](./with-resource), and the direction on mutations from the community -- Key Features: +- 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`) - - - Flattening operators -- `httpMutation` and `rxMutation` as standalone _functions_ that can be used outside of a store -- `withMutations` store _feature_, and the usage of `httpMutation` and `rxMutation` functions inside the feature -- Differences between `httpMutation` and `rxMutation` -- Full example of a usecase of both mutations in a `withMutations()` and then used in a component + + - State signals available (`value/status/error/isPending`) + `hasValue` signal to narrow type- `httpMutation` and `rxMutation` + - [How to use](#usage-withmutations-or-solo-functions), as: + - _standalone functions_ + - In `withMutations` store _feature_ +- [Differences](#choosing-between-rxmutation-and-httpmutation) between `httpMutation` and `rxMutation` +- [Full examples](#full-example) of + - Both mutations in a `withMutations()` + - Standalone functions in a component 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. @@ -60,21 +64,21 @@ Libraries like Angular Query offer a [Mutation API](https://tanstack.com/query/l 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 +## Key features (summary) Each mutation has the following: 1. Parameters to pass to an RxJS stream (`rxMutation`) or RxJS agnostic `HttpClient` call (`httpMutation`) -1. (optional) callbacks: `onSuccess` and `onError` +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. State signals: `value/status/error/isPending/hasValue` -1. Flattening operators ### Params -```ts -// 1. Params + call +See dedicated section on [choosing between `rxMutation` and `httpMutation`](#choosing-between-rxmutation-and-httpmutation) +```ts // RxJS stream rxMutation({ operation: (params: Params) => { @@ -103,8 +107,9 @@ httpMutation({ ### Callbacks +In the mutation: _optional_ `onSuccess` and `onError` callbacks + ```ts -// 2. In the mutation: *optional* `onSuccess` and `onError` callbacks ({ onSuccess: (result) => { // optional @@ -120,11 +125,32 @@ httpMutation({ }); ``` -### Methods +### Flattening operators + +Enables handling race conditions ```ts -// 3. Enables the method (returns a promise) +// Default: concatOp +// All options: concatOp, exhaustOp, mergeOp, switchOp +increment: rxMutation({ + // ... + operator: concatOp, // default if `operator` omitted +}), + +saveToServer: httpMutation({ + // ... + // Passing in custom option. Need to import like: + // `import { switchOp } from '@angular-architects/ngrx-toolkit'` + operator: switchOp, +}), +``` + +### Methods + +Enables the method (returns a promise) + +```ts // Call directly store.increment({...}); mutationName.saveToServer({...}); @@ -137,7 +163,7 @@ const save = await store.save({...}); if (inc.status === 'error') ### Signal values ```ts -// 4. Enables the following signal states +// 5. Enables the following signal states // via store store.increment.value; // also status/error/isPending/status/hasValue; @@ -146,28 +172,6 @@ store.increment.value; // also status/error/isPending/status/hasValue; mutationName.value; // ^^^ ``` -### Flattening operators - -```ts -// 5. Enables handling race conditions - -// All options: concatOp, exhaustOp, mergeOp, switchOp -// Default: concatOp - - -increment: rxMutation({ - // ... - operator: concatOp, // default if `operator` omitted -}), - -saveToServer: httpMutation({ - // ... - // Passing in custom option. Need to import like: - // `import { switchOp } from '@angular-architects/ngrx-toolkit'` - operator: switchOp, -}), -``` - ### Usage: `withMutations()` or solo functions Both of the mutation functions can be used either @@ -202,14 +206,16 @@ export const CounterStore = signalStore( 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 +### Key features (in depth) Each mutation has the following: -- Passing params via RxJS or RxJS-less `HttpClient` signature - see last section on difference +- 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` +- (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. @@ -233,7 +239,7 @@ storeName.mutationName.value; // or other signals mutationName.value; // ^^^ ``` -#### (optional) Callbacks: `onSuccess` and `onError` +#### 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. @@ -266,6 +272,28 @@ class CounterMutation { } ``` +#### Flattening operators (optional to specify, has default) + +```ts +// Default: concatOp +// All options: concatOp, exhaustOp, mergeOp, switchOp + +(withMutations((store) => ({ + increment: rxMutation({ + // ... + operator: concatOp, // default if `operator` omitted + }), +})), + class SomeComponent { + private saveToServer = httpMutation({ + // ... + // Passing in custom option. Need to import like: + // `import { switchOp } from '@angular-architects/ngrx-toolkit'` + operator: switchOp, + }); + }); +``` + #### Methods A mutation is its own function to be invoked, returning a promise should you want to await one. @@ -307,7 +335,8 @@ For brevity, take `rx` as `rxMutation` and `http` for `httpMutation` - 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 - + + ## Full example @@ -315,7 +344,7 @@ Our example application in the repository has more details and implementations, 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 mutations (ex - store) +### Declare ```ts import { concatOp, httpMutation, rxMutation, withMutations } from '@angular-architects/ngrx-toolkit'; @@ -369,17 +398,14 @@ export const CounterStore = signalStore( })), ); -function createSumObservable(a: number, b: number): Observable { - // ... -} - +// return of(a + b); function calcSum(a: number, b: number): Observable { - // return of(a + b); + function createSumObservable(a: number, b: number): Observable {...} return createSumObservable(a, b).pipe(delay(500)); } ``` -### Use (ex - component) +### Use ```ts @Component({...}) From 6d798c10c679be6065b9120af963421c151082e8 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 15:19:17 -0500 Subject: [PATCH 16/36] docs(mutations): fix typos --- docs/docs/mutations.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index efa47d4..2366bfd 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -140,7 +140,7 @@ increment: rxMutation({ saveToServer: httpMutation({ // ... - // Passing in custom option. Need to import like: + // Passing in a custom option. Need to import like: // `import { switchOp } from '@angular-architects/ngrx-toolkit'` operator: switchOp, }), @@ -244,8 +244,6 @@ mutationName.value; // ^^^ 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. -To shake up the examples, lets define an `onSuccess` in a `withMutations()` using store and an `onError` in a mutation which is a member of a component. - ```ts export const CounterStore = signalStore( // ... @@ -287,7 +285,7 @@ class CounterMutation { class SomeComponent { private saveToServer = httpMutation({ // ... - // Passing in custom option. Need to import like: + // Passing in a custom option. Need to import like: // `import { switchOp } from '@angular-architects/ngrx-toolkit'` operator: switchOp, }); @@ -331,7 +329,7 @@ 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 HTTP request. The user's API is agnostic of RxJS. _Technically, HttpClient with observables is used under the hood_. + - `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, - `http` takes parts of `HttpClient`'s method signature, or a `request` object which accepts those parts From e990da5275e780a8b87b9bb6363d3319f6fbb8e8 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 15:31:02 -0500 Subject: [PATCH 17/36] docs(mutations): mention resource non-GET edge case... --- 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 2366bfd..bde346a 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -54,7 +54,7 @@ the "Why" and "Who" of why mutations were built for the toolkit like this. 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](https://github.com/angular/angular/discussions/60120)) and (#2, [APIs](https://github.com/angular/angular/discussions/60121)), 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.** +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 fundamental `resource`) 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](https://github.com/angular/angular/discussions/60121) From 289d99586636b979fda6e123eeb8bb32d2f56449 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 15:34:31 -0500 Subject: [PATCH 18/36] docs(mutaitons): mention summary section on where to use --- 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 bde346a..9092834 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -74,6 +74,8 @@ Each mutation has the following: 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. + ### Params See dedicated section on [choosing between `rxMutation` and `httpMutation`](#choosing-between-rxmutation-and-httpmutation) @@ -210,8 +212,6 @@ The mutation functions can be used in a `withMutations()` feature, but can be us 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` From d495c087a1249f73409c3b7e3d3af62ac28b9963 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 7 Sep 2025 15:42:13 -0500 Subject: [PATCH 19/36] chore(mutations): remove TODO --- docs/docs/mutations.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 9092834..4866f75 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -47,8 +47,6 @@ the "Why" and "Who" of why mutations were built for the toolkit like this. ## Background - - ### Why not handle mutations using [`withResource`](./with-resource)? The `resource` API and discussion about it naturally lead to talks about all async operations. From 91842734297db27d7735009afc55482da3e8eff5 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 19:54:17 -0500 Subject: [PATCH 20/36] 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 21/36] 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 22/36] 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 23/36] 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 24/36] 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 25/36] 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 26/36] 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 27/36] 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 28/36] 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 29/36] 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 30/36] 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 31/36] 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 }); }, From 3ad0061d5d1316770789c58788152104b57204c3 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:50:02 -0500 Subject: [PATCH 32/36] docs(mutations): add link from homepage --- docs/docs/extensions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/extensions.md b/docs/docs/extensions.md index 535787f..94ad0e0 100644 --- a/docs/docs/extensions.md +++ b/docs/docs/extensions.md @@ -12,6 +12,7 @@ It offers extensions like: - [Immutable State Protection](./with-immutable-state): Protects the state from being mutated outside or inside the Store. - [~Redux~](./with-redux): Possibility to use the Redux Pattern. Deprecated in favor of NgRx's `@ngrx/signals/events` starting in 19.2 - [Resource](./with-resource): Integrates Angular's Resource into SignalStore for async data operations +- [Mutations](./mutations): Seek to offer an appropriate equivalent to signal resources for sending data back to the backend - [Reset](./with-reset): Adds a `resetState` method to your store - [Call State](./with-call-state): Add call state management to your signal stores - [Storage Sync](./with-storage-sync): Synchronizes the Store with Web Storage From 2133463b61a5d452fd268768a2306a55813ed8f4 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:51:40 -0500 Subject: [PATCH 33/36] docs(mutation): remove numbering in values --- 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 c48b46b..765ee67 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -170,7 +170,7 @@ const save = await store.save({...}); if (inc.status === 'error') ### Signal values ```ts -// 5. Enables the following signal states +// Signal states // via store store.increment.value; // also status/error/isPending/status/hasValue; From 83dfa3ed53ab5a27a5e5e468647181d41334cd7a Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:55:31 -0500 Subject: [PATCH 34/36] docs(mutations): add preview example --- docs/docs/mutations.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index 765ee67..cde1864 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -43,6 +43,37 @@ This guide covers - Both mutations in a `withMutations()` - Standalone functions in a component +```ts + 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. From befa4262b5836b244da29be15c73936c4f9e7646 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:57:09 -0500 Subject: [PATCH 35/36] docs(mutations): use wording for signals !== signalstate --- 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 cde1864..abd8395 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -201,7 +201,7 @@ const save = await store.save({...}); if (inc.status === 'error') ### Signal values ```ts -// Signal states +// Signals // via store store.increment.value; // also status/error/isPending/status/hasValue; From 32da9c8304892049d11f532c1ecf25c2ec50e0d4 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 10 Sep 2025 20:59:12 -0500 Subject: [PATCH 36/36] docs(mutation): resolve a TODO --- docs/docs/mutations.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/docs/mutations.md b/docs/docs/mutations.md index abd8395..6de8108 100644 --- a/docs/docs/mutations.md +++ b/docs/docs/mutations.md @@ -381,8 +381,6 @@ For brevity, take `rx` as `rxMutation` and `http` for `httpMutation` - `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 - - ## Full example Our example application in the repository has more details and implementations, but here is a full example in a store using `withMutations`.