Angular's reactivity story has never felt this good.
With Angular 22, resource() and httpResource() are stable. Fetching data is simple: describe what you depend on, read the resulting signals, and let Angular handle the reactive plumbing.
Then you need to save something.
Angular deliberately does not use resource() for writes. So what should a Signal-native write API look like?
That's the problem @ngsignal/mutation tries to solve. Its API mirrors Angular's resource family:
| Reads | Writes | Input |
|---|---|---|
resource() |
mutation() |
Promise |
rxResource() |
rxMutation() |
Observable |
httpResource() |
httpMutation() |
HTTP request description |
Make writes feel like Angular resources, without building another data layer.
What it really provides is not a lot of code. It is a behavior contract for writes: state, errors, lifecycle, SSR and concurrency.
Cancelling a read discards work. Cancelling a write doesn't undo it.
The resource API is intended for reads, and the httpResource guide recommends using HttpClient directly for POST and PUT. The Resource RFC sums it up: a resource declares a dependency on data; it is not an imperative primitive for mutations.
The reason: cancellation means something different for a write. A read can become obsolete when a newer read starts. A write may already have changed the server.
Imagine POST /orders. The server creates the order, then the browser stops waiting for the response. You can abort the request on the client; you cannot abort what the server already did.
For reads, cancellation discards stale work. For writes, it can make you lose the result of work that already happened.
So Angular leaves the primitive to HttpClient and lets application libraries decide how to model write state.
The problem is that every application can end up rebuilding the same state machine.
"Just use HttpClient" gets complicated fast
A saving signal, an error signal and a call to post(): easy. Then real usage starts:
- Race conditions: an older response overwrites the state of a newer request.
- Double submits: three clicks create three writes.
- Ordering: two updates reach the server in the wrong order.
- Lifecycle: a component disappears mid-request.
- SSR: rendering needs to know when pending work has settled.
The HTTP call is easy. The state machine is the hard part.
Why not use a bigger data library?
TanStack Query offers mutations inside a full data-synchronization system (cache, queries, devtools, invalidation), and NgRx Toolkit offers mutation primitives with RxJS concurrency strategies. Both are great when you need that.
@ngsignal/mutation takes the opposite approach:
No store. No cache. No provider. No RxJS in the core. Just a small Signal-native primitive for writes.
Meet mutation()
npm install @ngsignal/mutation
A mutation is just a function that turns an input into a Promise:
readonly createTodo = mutation<string, Todo>({
mutationFn: (title, abortSignal) =>
this.api.createTodo(title, abortSignal),
onSuccess: (todo) => {
this.todos.update((list) => [...list, todo]);
},
});
That's the imperative side.
The other side is reactive: mutation() exposes read-only signals for its status, value, error, input and derived states such as isPending() and isSuccess(). The template reads them directly:
<button (click)="createTodo.mutate(title())">
{{ createTodo.isPending() ? 'Saving…' : 'Add todo' }}
</button>
@if (createTodo.isError()) {
<p class="error">Could not save the todo.</p>
}
mutate() returns a Promise, but you can safely ignore it. The two have different jobs: error() is UI state and holds the error of the latest call, while the Promise carries the result of that specific call. Await it only when the caller needs that result. Either way, no call, dropped ones included, ever produces an unhandled rejection: the library handles every rejection, and an awaiting caller still receives it.
So the model becomes:
Imperative trigger. Reactive state.
The important part is that common write problems now have explicit semantics:
| Problem | Behavior |
|---|---|
| Race conditions | Only the latest submitted call owns the reactive state; every executed call still runs its callbacks |
| Double submits |
concurrency: 'drop' rejects new calls while one is pending |
| Ordering |
concurrency: 'queue' runs calls sequentially |
| Lifecycle | Destroyed contexts cannot commit stale results |
| SSR | In-flight work participates in PendingTasks
|
That is most of the API.
What it replaces
For comparison, here is the same write done by hand with the same guarantees (stale-response guard, abort on destroy, SSR pending task) but without any queue or drop logic yet:
readonly status = signal<'idle' | 'pending' | 'success' | 'error'>('idle');
readonly error = signal<unknown>(undefined);
private generation = 0;
private destroyed = false;
private readonly controllers = new Set<AbortController>();
private readonly pendingTasks = inject(PendingTasks);
constructor() {
inject(DestroyRef).onDestroy(() => {
this.destroyed = true;
this.controllers.forEach((controller) => controller.abort());
});
}
async createTodo(title: string) {
const generation = ++this.generation;
const controller = new AbortController();
this.controllers.add(controller);
this.status.set('pending');
this.error.set(undefined);
const removeTask = this.pendingTasks.add();
try {
const todo = await this.api.createTodo(title, controller.signal);
if (this.destroyed) return;
this.todos.update((list) => [...list, todo]);
if (generation !== this.generation) return;
this.status.set('success');
} catch (error) {
if (this.destroyed || generation !== this.generation) return;
this.status.set('error');
this.error.set(error);
} finally {
removeTask();
this.controllers.delete(controller);
}
}
Thirty-odd lines, repeated for every write in the application. The live demo has both versions of the same page side by side (/ and /manual).
rxMutation() and httpMutation()
Your services already return Observables? Keep them:
import { rxMutation } from '@ngsignal/mutation/rxjs-interop';
readonly createUser = rxMutation<CreateUserDto, User>({
mutationFn: (dto) => this.userService.create(dto),
});
A write has one outcome, so the first emitted value wins and the subscription is dropped. RxJS stays out of the core bundle.
httpMutation() goes further: describe the request, the same way as with httpResource(), and get HTTP signals back. Say, a file upload with a progress bar:
import { httpMutation } from '@ngsignal/mutation/http';
readonly upload = httpMutation<File, Attachment>({
request: (file) => ({
method: 'POST',
url: '/api/attachments',
body: file,
reportProgress: true,
}),
});
@if (upload.uploadProgress(); as progress) {
<progress [value]="progress.loaded" [max]="progress.total"></progress>
}
Validating the response takes one option:
readonly createUser = httpMutation<CreateUserDto, User>({
request: (dto) => ({ method: 'POST', url: '/api/users', body: dto }),
parse: (body) => userSchema.parse(body), // Zod, Valibot, or plain code
});
And it exposes a subtle case. When parsing throws, the user was already created: the status says error, the status code says 201. The HTTP request succeeded; processing its response failed. Retry blindly and you create it twice.
Response headers, download progress and a .blob() variant (for a PDF generated by a POST) are there too.
Two practical details
-
Upload progress needs
reportProgress: truein the request description and, on Angular 22,provideHttpClient(withXhr()): the defaultfetchbackend silently ignores upload progress. -
Writes that must outlive a component (save-on-leave, for example) belong in a longer-lived service or need an appropriate
EnvironmentInjector, because destroying the injection context aborts in-flight work. Aborting only stops the client from waiting; it does not roll back what the server already did.
The interesting part comes next.
The key design choice: a new call never cancels the previous one
A new mutate() call does not abort the previous one; only reset() or destroying the injection context does. Each call gets a generation number, and only the latest one writes the signals.
"This result is stale" does not mean "this write must be cancelled."
That is exactly what switchMap semantics get wrong for writes.
The callbacks, though, belong to every call: a superseded write still happened on the server. Create two todos quickly and both are appended; a superseded call that fails still reports its error. For side effects that should only follow the current call, callbacks receive { isLatest }:
onSuccess: (todo, title, { isLatest }) => {
this.todos.update((list) => [...list, todo]); // every call
if (isLatest) this.form.reset(); // current call only
},
Ordered writes in eight lines
Parallel writes can be processed out of order, and that breaks when order matters.
Think autosave: draft A → draft B → draft C sent together can be processed out of order. With concurrency: 'queue', a request is only sent once the previous one settles:
readonly saveDraft = httpMutation<Draft, Draft>({
request: (draft) => ({
method: 'PUT',
url: `/api/drafts/${draft.id}`,
body: draft,
}),
concurrency: 'queue',
});
The queue itself is tiny:
let queueTail: Promise<void> = Promise.resolve();
function enqueue<T>(task: () => Promise<T>): Promise<T> {
const result = queueTail.then(task, task);
queueTail = result.then(
() => undefined,
() => undefined,
);
return result;
}
No array, no scheduler, no RxJS. queueTail.then(task, task) starts the next task once the previous one settles, success or failure. The tail swallows that outcome so one failure cannot poison the queue, while each caller still gets its own result.
Because requests are built when they start, an auth token or an ETag is read at send time, not at click time. And reset() aborts calls still waiting, so they are never sent.
The queue orders requests as the client sends them. If the server needs stronger guarantees, versioning or ETag-based concurrency control still apply.
Three concurrency modes
| Mode | Good fit | RxJS analogue |
|---|---|---|
| Default | Independent actions |
mergeMap-like execution, latest call owns the state |
'queue' |
Autosave, ordered updates | concatMap |
'drop' |
Submit, payment | exhaustMap |
| — | — | No switchMap: a new write never aborts the previous one |
There is no switchMap because switching means aborting the previous write. A dropped call never touches the signals or the callbacks: only its Promise rejects, with an AbortError, which matters only if you await it.
Where the project stands
@ngsignal/mutation is at 0.6.0, supports Angular 21 and 22, runs CI, CodeQL and OpenSSF Scorecard, and tests its RxJS interop against RxJS 6 and 7. It is still experimental: pin an exact version.
Imperative work in. Reactive state out.
Try it
⭐ GitHub
📦 npm
⚡ Live demo: every section of this article has its own page, from /concurrent-deletion to /concurrency-queue.
The most useful feedback is about the API itself: concurrency semantics, HTTP progress, and the write strategies you need in real applications. Issues and PRs are welcome.
And if Angular eventually ships an official mutation primitive, this project will still have been a worthwhile experiment. 🙂






Top comments (0)