Skip to content

injectMutation

injectMutation() creates a reactive caller for a Convex mutation: an imperative mutate() method plus readonly Signals describing the most recent call.

function injectMutation<Mutation extends MutationReference>(
mutation: Mutation,
options?: MutationOptions<Mutation>,
): MutationResult<Mutation>;

MutationReference is FunctionReference<'mutation'>, so any mutation from your generated api object is accepted.

Call it from an injection context — a component or service field initializer or constructor — or pass an explicit injector through the injectRef option to create it from plain code later. Without either, it throws.

add-todo.component.ts
import { Component } from '@angular/core';
import { ConvexError, injectMutation } from 'convex-angular';
import { api } from './convex/api';
@Component({
selector: 'app-add-todo',
template: `
<button (click)="add()" [disabled]="createTodo.isLoading()">Add todo</button>
@if (createTodo.error(); as error) {
<p role="alert">{{ error.message }}</p>
}
`,
})
export class AddTodoComponent {
readonly createTodo = injectMutation(api.todos.create, {
onSuccess: (id) => console.log('created', id),
});
async add(): Promise<void> {
try {
// `mutate()` rejects on failure *and* mirrors the error into `error()`.
await this.createTodo.mutate({ title: 'Buy groceries' });
} catch (error) {
// Narrow application errors thrown by your Convex function to read
// their typed payload.
if (error instanceof ConvexError) {
console.error(error.data);
}
}
}
}

MutationOptions<Mutation>:

Name Type Default Description
injectRef EnvironmentInjector Injector used to resolve ConvexClient and DestroyRef when the mutation is created outside the ambient injection context. The mutation’s lifetime is bound to that injector.
onSuccess (data: FunctionReturnType<Mutation>) => void Called after data is written, for the latest call only.
onError (err: Error) => void Called after error is written, for the latest call only. Receives the normalized Error.
optimisticUpdate OptimisticUpdate<FunctionArgs<Mutation>> Local query-store update applied while the mutation is in flight. See Optimistic updates.

The option object is passed straight through to Convex as convex.mutation(mutation, args, { optimisticUpdate }).

MutationResult<Mutation>:

Name Type Description
mutate (args: FunctionArgs<Mutation>) => Promise<FunctionReturnType<Mutation>> Runs the mutation. Resolves with its return value, rejects on failure.
data Signal<FunctionReturnType<Mutation> | undefined> Return value of the last successful call. undefined until a call succeeds, and again while a new call is in flight or after reset().
error Signal<Error | undefined> Error from the last failed call, undefined otherwise.
isLoading Signal<boolean> true while the latest call is in flight.
isSuccess Signal<boolean> true only when a call has completed, nothing is loading, and there is no error.
status Signal<MutationStatus> 'idle' | 'pending' | 'success' | 'error'.
reset () => void Clears data, error, and isLoading, and returns status to 'idle'.

error() is always a real Error. Errors your Convex function throws with ConvexError keep their typed payload — narrow with error() instanceof ConvexError to read .data.

delete-todo.component.ts
import { Component, input } from '@angular/core';
import { injectMutation } from 'convex-angular';
import { api, type TodoId } from './convex/api';
@Component({
selector: 'app-delete-todo',
template: `
<button type="button" (click)="remove()" [disabled]="removeTodo.isLoading()">Delete</button>
@switch (removeTodo.status()) {
@case ('idle') {
<!-- Never called, or reset() was called. -->
}
@case ('pending') {
<span>Deleting…</span>
}
@case ('success') {
<span>Deleted</span>
<button type="button" (click)="removeTodo.reset()">Dismiss</button>
}
@case ('error') {
<span role="alert">{{ removeTodo.error()?.message }}</span>
<button type="button" (click)="removeTodo.reset()">Dismiss</button>
}
}
`,
})
export class DeleteTodoComponent {
readonly todoId = input.required<TodoId>();
readonly removeTodo = injectMutation(api.todos.remove);
remove(): void {
// The rejection is already mirrored into `removeTodo.error()`, but the
// promise still rejects, so it must be handled to avoid an unhandled
// rejection.
void this.removeTodo.mutate({ id: this.todoId() }).catch(() => undefined);
}
}

Mutations and actions share one implementation, so the rules below apply identically to mutate() and run().

mutate() clears data and error and sets isLoading to true synchronously, before the request is awaited. Reading the signals immediately after the call — without awaiting anything — already shows status() === 'pending' and data() === undefined. A previous success is therefore not visible while a new call is running.

Every call takes a version number. When a call settles, it only writes to data, error, and isLoading and only fires onSuccess / onError if it is still the latest call. A superseded call still settles its own promise with its own value or error — the caller of that promise sees the real outcome — but it is invisible to the reactive state.

That also means isLoading stays true when an older call finishes while a newer one is still in flight: isLoading tracks the latest call, not the number of outstanding requests.

Current state Event Resulting status
idle mutate() called pending
success or error mutate() called pending (previous data / error cleared synchronously)
pending latest call resolves success, data set, onSuccess fired
pending latest call rejects error, error set, onError fired
pending superseded call resolves pending — unchanged, no callback
pending superseded call rejects pending — unchanged, no callback
any reset() idle, data and error cleared
any in-flight call settles after reset() unchanged, no callback
any owning scope destroyed idle, and all later settlements are ignored
// Reading state only — the rejection still needs a handler.
void save.mutate({ title }).catch(() => undefined);
// Or handle it directly.
try {
await save.mutate({ title });
} catch (err) {
// `err` is the exact same Error instance as `save.error()`.
}

A rejection value that is not an Error is wrapped as new Error(String(err)). The wrapped instance is both stored in error() and thrown, so err === save.error() holds for the latest call in every failure case.

Calling reset() between two calls also prevents the older one from inheriting the newer one’s identity — a stale result can never be mistaken for a fresh one.

When the component, service, or injectRef injector that owns the mutation is destroyed, the state is reset and marked destroyed. From that point on:

  • calls already in flight settle their promises but touch no signals and fire no callbacks;
  • new mutate() calls still go to the network and still resolve or reject, but they bypass the reactive state entirely — status() stays 'idle'.