mutationResource
The write. It fires only when you call mutate(value), and its lifecycle hooks give you optimistic updates that roll back on failure.
@mmstack/resourcenpm
A query keeps read data in sync on its own. A write is different: it's a command you issue at a moment you choose, on data the user just produced. So a mutation doesn't run reactively. It sits there until you call mutate(), sends the request, and reports how it went.
The reason it's a dedicated resource and not a bare HttpClient call is the lifecycle around the request. Those hooks are what let you update the screen before the server answers and undo it cleanly if the server says no.
#Firing a mutation
The request function receives the value you pass to mutate() and returns the request to send. A mutation cannot be cached, so it rejects the cache, keepPrevious, and refresh options by design.
import { mutationResource } from '@mmstack/resource';
readonly createPost = mutationResource((post: Post) => ({
url: '/api/posts',
method: 'POST',
body: post,
}));
// somewhere in a handler:
this.createPost.mutate(newPost);mutate() is fire-and-forget. When you need the outcome in line, for example to navigate after a save or to fail a validator, use mutateAsync(). It returns a promise that resolves with the result or rejects with the error.
import { MutationCancelledError } from '@mmstack/resource';
async onSubmit(post: Post) {
try {
const saved = await this.createPost.mutateAsync(post);
this.router.navigate(['/posts', saved.id]);
} catch (e) {
if (e instanceof MutationCancelledError) return; // never ran
this.toast.error('Failed to save');
}
} The MutationCancelledError guard matters here. If a mutation never completes (a newer one supersedes it, the component is destroyed, the queue is cleared), the promise rejects with that error rather than a real failure. An awaiter that isn't expecting cancellation should return early on it, as above.
Because a mutation is a command, calling mutate() with a body identical to one already in flight is deduplicated, which doubles as double-click protection. When a repeat with the same body must fire anyway (a "resend" button, re-uploading the same file), set triggerOnSameRequest: true and every call goes through.
#Lifecycle hooks
Four hooks run around the request. onMutate fires synchronously before it and can return a context value. onError and onSuccess fire on their respective branch and receive that context. onSettled runs after either one, for cleanup.
mutationResource((post: Post) => ({ url: '/api/posts', method: 'POST', body: post }), {
onMutate: (post) => {
// fires synchronously before the request; return a ctx value
return 'anything';
},
onError: (err, ctx) => {
// fires on failure; ctx is what onMutate returned
},
onSuccess: (saved, ctx) => {
// fires on success
},
onSettled: (ctx) => {
// fires after either branch; cleanup, refetch, etc.
},
}); The context returned from onMutate is the thread that ties them together. It flows into the later hooks, which is exactly what an optimistic update needs: stash the old state on the way in, restore it on failure.
#Optimistic update and rollback
This is the pattern the hooks were built for. Apply the change to a query's value in onMutate so the UI updates instantly, and return the previous value as context. If the request fails, onError restores it. On success, onSuccess swaps the optimistic entry for the real server record (which now has its id, timestamps, and so on).
import { untracked } from '@angular/core';
readonly createPost = mutationResource(
(post: Post) => ({ url: '/api/posts', method: 'POST', body: post }),
{
onMutate: (post) => {
const prev = untracked(this.posts.value);
this.posts.set([...prev, post]); // apply optimistically
return prev; // ctx for rollback, inferred & type-safe
},
onError: (_err, prev) => this.posts.set(prev), // roll back
onSuccess: (saved) =>
this.posts.update((posts) =>
posts.map((p) => (p.id === saved.id ? saved : p)),
),
},
); Read the previous value with untracked(). Reading a signal inside onMutate without it would register a dependency you don't want. If reasoning about rollback by hand feels fiddly, the payoff is real: the interface never waits on the network for changes that almost always succeed. The Optimistic updates page goes further, including patching cached lists and how rollback behaves when a mutation replays after coming back online.
#Refreshing queries after a write
A write usually means some cached reads are now stale. Instead of reaching into the cache from onSuccess by hand, declare what to invalidate. After the mutation succeeds, every cached entry under those URL prefixes is dropped, so the next read refetches.
readonly createPost = mutationResource(
(post: Post) => ({ url: '/api/posts', method: 'POST', body: post }),
{
invalidates: ['/api/posts'], // drop cached reads under this prefix
// or derive from the result:
// invalidates: (saved) => ['/api/posts', `/api/users/${saved.authorId}`],
},
);Strings are URL prefixes, matched against every cached entry regardless of HTTP method. You can also pass a function to derive the prefixes from the result, for example to also invalidate the affected user's page.
Matching works by recovering the URL from the default key shape, so it keeps working even for keys a custom cache.hash merely prepends a namespace to. If your keys abandon that shape entirely, invalidation can't locate them. Teach it how with invalidateMatcher, which maps each invalidated URL prefix to a predicate over your custom keys. Set it per mutation, or globally through provideMutationResourceOptions.
readonly createPost = mutationResource(
(post: Post) => ({ url: '/api/posts', method: 'POST', body: post }),
{
invalidates: ['/api/posts'],
// custom keys that don't follow the default shape:
invalidateMatcher: (urlPrefix) => (key) => key.includes(urlPrefix),
},
);#Queuing and offline
By default, calling mutate() while another is in flight starts it immediately, so writes run in parallel. With queue: true they serialize, one at a time. The queue survives disabled states: if the circuit breaker opens or the network drops, queued writes wait and run when the resource recovers.
readonly saveNote = mutationResource(
(note: Note) => ({ url: '/api/notes', method: 'POST', body: note }),
{
queue: true, // serialize; hold pending while offline, run on recovery
},
); That resilience has a flip side. A queued mutation can fire long after the user triggered it (the classic "POST goes out when we're back online"). Don't enable queue where that timing would surprise someone. Take it one step further with persist and accepted-but-unsettled writes survive an app close in IndexedDB and replay on the next launch, which is worth reading up on before you rely on it.
When queued writes should be abandoned (the user leaves the flow, a draft is discarded), clearQueue() drops everything still pending. Any awaiter on a dropped mutation rejects with a MutationCancelledError, so the same guard from mutateAsync covers it.
// user discards the draft: drop anything still waiting to send
this.saveNote.clearQueue();#File uploads with progress
There is no separate upload API. Return a FormData body and HttpClient sets the multipart boundary for you. Opt into progress events with reportProgress: true and read the progress signal.
readonly upload = mutationResource<UploadResult, UploadResult, FormData>(
(form) => ({ url: '/api/upload', method: 'POST', body: form, reportProgress: true }),
);
// trigger it:
const form = new FormData();
form.append('file', file);
this.upload.mutate(form);
// a percentage for the bar:
readonly pct = computed(() => {
const p = this.upload.progress();
return p?.total ? Math.round((p.loaded / p.total) * 100) : null;
});#Submit a form()
Angular Signal Forms runs your write through submit(form, action): the action runs only when the form is valid, and submit resolves to a boolean for whether it went through. The action is an ordinary async function, so mutateAsync drops straight in.
import { submit } from '@angular/forms/signals';
import { MutationCancelledError, mutationResource } from '@mmstack/resource';
readonly save = mutationResource(
(user: User) => ({ url: '/api/users', method: 'POST', body: user }),
);
async onSubmit() {
const saved = await submit(this.form, async () => {
try {
await this.save.mutateAsync(this.form().value());
} catch (err) {
if (err instanceof MutationCancelledError) return; // superseded, not a failure
return { kind: 'server', message: 'Could not save. Try again.' };
}
});
if (saved) this.router.navigate(['/users']);
} On failure you have a choice. Return validation errors from the action and submit attaches them to the form (a bare { kind, message } lands on the form, or add a field to target one control), which is how you surface a server rejection like "email already taken" next to the input. Guard MutationCancelledError first so a superseded write is not reported as a real failure.
When you only want to send what changed and re-baseline on success, @mmstack/forms ships submitChanges, which is this recipe wrapped up with a mutationResource as its target.
#The return shape
A mutation ref is deliberately narrower than a query's. It has no value, hasValue, or prefetch, because those don't make sense for a one-off write.
| Member | What it is |
|---|---|
mutate(value, ctx?) | Trigger it, fire-and-forget. |
mutateAsync(value, ctx?) | Trigger and await the result. |
current() | The value being mutated, or null when idle. |
progress() | Upload progress when reportProgress: true. |
clearQueue() | Drop all pending queued mutations. Awaiters reject with MutationCancelledError. |
status() / error() / isLoading() | The same status surface as a query. |