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.
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); } } }}Options
Section titled “Options”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 }).
Result
Section titled “Result”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.
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); }}The callable contract
Section titled “The callable contract”Mutations and actions share one implementation, so
the rules below apply identically to mutate() and run().
State is cleared synchronously
Section titled “State is cleared synchronously”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.
Last call wins
Section titled “Last call wins”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 |
Failures reject and populate error()
Section titled “Failures reject and populate error()”// 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()`.}Non-Error throws are normalized
Section titled “Non-Error throws are normalized”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.
reset() orphans in-flight calls
Section titled “reset() orphans in-flight calls”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.
After the owning scope is destroyed
Section titled “After the owning scope is destroyed”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'.
Related
Section titled “Related”- Optimistic updates — instant UI feedback with
optimisticUpdate. - injectAction — the same contract for Convex actions.