DEV Community

Erwan Raulo
Erwan Raulo

Posted on

Angular Resource for reads. Here's the missing piece for writes

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.

Cancelling a GET discards a stale read, but cancelling a POST /orders leaves order #1042 on the server while the client shows no order

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
Enter fullscreen mode Exit fullscreen mode

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]);
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
});
Enter fullscreen mode Exit fullscreen mode

A write has one outcome, so the first emitted value wins and the subscription is dropped. RxJS stays out of the core bundle.

rxMutation marble diagram: the first emitted value resolves the mutation and unsubscribes; an Observable that completes without a value rejects

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,
  }),
});
Enter fullscreen mode Exit fullscreen mode
@if (upload.uploadProgress(); as progress) {
  <progress [value]="progress.loaded" [max]="progress.total"></progress>
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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.

httpMutation: the server creates the user and returns 201, then parse throws, so status() is 'error' while statusCode() is 201

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: true in the request description and, on Angular 22, provideHttpClient(withXhr()): the default fetch backend 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.

Two mutate() calls: A finishes after B and is not aborted; only B, the latest call, commits to the signals, but both run onSuccess, B with isLatest true and A with isLatest false

"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
},
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

The eight-line Promise queue: A rejects, the tail swallows the failure, B and C still run, and only C commits to the signals

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

The same three clicks with the default, 'queue' and 'drop' concurrency modes

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)