With Signals, reactive programming, and thus a data-flow-centric perspective, becomes the focus of development. Thanks to the Resource API, asynchronous operations such as data loading, can also be executed as part of the reactive flow. However, writing back to the server and including forms have remained open until now.
The new Mutation API from our popular community project NgRx Toolkit, as well as Signal Forms recently released as a prototype with Angular 21.0.0-next.2, close precisely this gap. In this article, I'll show how this and the NgRx Signal Store can be used to model a clear reactive flow: from the backend with Resources to a Signal Form and back again via the Mutation API.
Example application
The deliberately simple example application used here allows you to edit a loaded flight:
Behind this simple solution lies a data flow that covers both loading and storing. An NgRx Signal Store uses the Resource API to load the data and the Mutation API from the NgRx Toolkit for storing:
Both the resource and the mutation provide Signals that the component's store exposes to the component. These include the retrieved data, states such as isLoading and isProcessing, and any error information. The Signal Form displays the loaded data and serves as the starting point for writing back changes via a mutation.
Loading Data With the Resource API
The data access service takes care of creating resources and mutations. It relies on an httpResource for loading:
@Injectable({
providedIn: 'root',
})
export class FlightService {
[…]
findResourceById(id: Signal<number>) {
return httpResource<Flight>(
() =>
!id()
? undefined
: {
url: 'https://demo.angulararchitects.io/api/flight',
params: {
id: id(),
},
},
{
defaultValue: initFlight,
}
);
}
}
Whenever the id in the passed Signal changes, the Resource recalculates the request object and then uses it to load the requested flight. There is one exception, however: If the id is falsy (e.g., 0) the request object receives the value undefined. By convention, the Resource does not load any data in this case. This procedure delays loading until an "real" id is actually available.
To prevent the Resource from returning the result undefined before the first call returns, the example defines a defaultValue. The initFlight serves as a Null Object.
Save Data With the Mutation API
The Mutation API provided by the NgRx Toolkit is the counterpart to Angular's Resource API for modifying data. In addition to an rxMutation for arbitrary changes that return their results as Observables, the httpMutation is suitable for changes that can be directly represented with HTTP messages:
import { httpMutation } from '@angular-architects/ngrx-toolkit';
[…]
@Injectable({
providedIn: 'root',
})
export class FlightService {
[…]
createSaveMutation(options: Partial<HttpMutationOptions<Flight, Flight>>) {
return httpMutation({
...options,
request: (flight) => ({
url: 'https://demo.angulararchitects.io/api/flight',
method: 'POST',
body: flight,
}),
operator: concatOp
});
}
[…]
}
Here, too, the data access service serves as a factory. The service itself defines the key data for the HTTP request; further configurations, such as a success or error callback, can be passed by the caller via a partial options object.
The HttpMutationOptions<Flight, Flight> type indicates that the mutation receives a Flight and returns another Flight. The latter corresponds to the stored flight, which also contains values assigned by the server, such as the id.
Optionally, you can also specify which semantics should be used for overlapping calls to prevent race conditions. The operator property accepts a corresponding strategy for this purpose. The NgRx Toolkit provides the strategies switchOp, mergeOp, concatOp, and exhaustOp, which correspond to the well-known flattening operators in RxJS. If the caller does not specify this property, concatOp is used. This causes the mutation to trigger overlapping requests one after the other.
The returned mutation can be called as a function and also provides state information in the form of Signals. The next listing illustrates this using a component that uses the mutation directly.
export class HomeComponent {
private flightService = inject(FlightService);
private saveFlight = this.flightService.createSaveMutation({ … })
private saveFlightIsPending = this.saveFlight.isPending;
private saveFlightError = this.saveFlight.error;
private saveFlightValue = this.saveFlight.value;
private saveFlightParams = this.saveFlight.isSuccess;
save(): void {
this.saveFlight({
id: 0,
from: 'Graz',
to: 'Hamburg',
date: new Date().toISOString(),
delayed: false,
});
}
}
More on this: Angular Architecture Workshop (Remote, Interactive, Advanced)
Become an expert for enterprise-scale and maintainable Angular applications with our Angular Architecture workshop!
English Version | German Version
Providing Resources and Mutations via the Signal Store
To manage state and keep the reactive flow manageable, the example shown here encapsulates the Resource and Mutation in an NgRx Signal Store. To bridge the gap with the store, the NgRx Toolkit provides the withMutations and withResource features:
import {
withMutations,
withResource,
} from '@angular-architects/ngrx-toolkit';
[…]
export const FlightDetailStore = signalStore(
{ providedIn: 'root' },
withState({
filter: {
id: 0,
},
}),
withProps(() => ({
_flightService: inject(FlightService),
_snackBar: inject(MatSnackBar),
})),
withResource((store) => ({
flight: store._flightService.findResourceById(store.filter.id),
})),
withMutations((store) => ({
saveFlight: store._flightService.createSaveMutation({
onSuccess(flight: Flight) {
patchState(store, { flightValue: flight });
store._snackBar.open('Flight saved', 'OK');
},
onError(error: unknown) {
store._snackBar.open('Error saving flight!', 'OK');
console.error(error);
},
}),
})),
withMethods((store) => ({
updateFilter: signalMethod((id: number) => {
if (id !== store.filter.id()) {
patchState(store, {
filter: {
id,
},
});
}
}),
}))
);
The feature withResource maps a name used by the store to the resource returned by the data access service shown above. Let's me put your attention to the following line:
patchState ( store , { flightValue: flight });
Here, the question arises as to where the value flightValue comes from. In fact, it's one of the properties introduced by withResource. By convention, this property starts with the name specified by withResource and returns the retrieved value. As we'll see below, the store is given additional properties following this pattern, including flightIsLoading and flightError.
The feature withMutations proceeds analogously and additionally defines a success and error handler. It sets up properties such as saveFlightIsPending and saveFlightError.
The method updateFilter sets the id managed by the store and thus triggers the Resource. Interestingly, the store configures this method as a signalMethod. As a result, the id can be passed not only as a number, but also as a Signal<number> and even as an Observable<number>. In the last two cases, the signalMethod calls the logic again as soon as a new value is available.
The Store and Signal Forms
The consuming component retrieves the store via dependency injection and provides a Signal Form for editing the loaded flight:
import { Control, form, required, submit } from '@angular/forms/signals';
[…]
export class FlightEditComponent {
private store = inject(FlightDetailStore);
id = input.required({
transform: numberAttribute,
});
isPending = this.store.saveFlightIsPending;
error = this.store.saveFlightError;
flight = linkedSignal(() => normalize(this.store.flightValue()));
flightForm = form(this.flight, (schema) => {
required(schema.from);
required(schema.to);
required(schema.date);
});
constructor() {
this.store.updateFilter(this.id);
}
save(): void {
submit(this.flightForm, async (form) => {
const result = await this.store.saveFlight(form().value());
if (result.status === 'error') {
return {
kind: 'processing_error',
// ^^^ try to be more specfic
error: result.error,
}
}
return null;
});
}
}
The interaction between the store and the form comes with a small challenge: While the form is responsible for modifying the loaded data, the store publishes this data as read-only to ensure consistency. The store only allows changes via provided methods.
This means that the data received from the store must be transferred to a local working copy. This is precisely what linkedSignal takes care of here. You can imagine it as a computed with a working copy to which the form's data binding can write back. Additionally, the shown call to linkedSignal delegates to the helper function normalize (not discussed in detail here), which formats the flight date to be used with an <input type="datatime-local">
To convert the resulting flight property into a Signal Form, the component passes it to the form function provided by Angular. The second parameter is passed a schema with validation rules. This results in the flightForm property, which allows the individual flight properties — e.g., from and to — to be bound to form fields.
To save, the component calls the submit function provided by Signal Forms. The passed lambda expression delegates to the saveFlight mutation and returns a validation result in the event of a server-side error. The Signal Form treats this validation result in the same way as those resulting from the validation rules in the specified schema.
Besides the error state, a mutation can result in two other states. The obvious one is success, but it's also possible that the mutation aborts an operation using switch or exhaust semantics to prevent race conditions. The aborted state indicates this case.
The next listing binds the individual fields of the flightForm to input fields and displays the validation errors:
@if (flightForm.id().value() !== 0) {
<pre>Form-level errors: {{ flightForm().errors() | json }}</pre>
<form class="flight-form" (ngSubmit)="save()">
<div class="form-group">
<label for="flight-from">From</label>
<input
class="form-control"
[control]="flightForm.from"
id="flight-from"
name="from"
/>
<pre>Field-level Errors: {{ flightForm.from().errors | json }}</pre>
</div>
[…]
<div class="mt-20">
<button
class="btn btn-default"
type="button"
(click)="save()">Save</button>
</div>
</form>
}
For simplicity, the component outputs the validation errors as a JSON document. A component that processes the detected errors accordingly would be useful here.
Conclusion
With Resource API, Mutations, and Signal Forms, Angular provides a consistent reactive data flow for the first time – from loading to editing to saving. The NgRx Signal Store serves as a framework to consistently bring these building blocks together. This closes the reactive loop and creates a clearly structured model for state and form management that is both robust and extensible.