Skip to content

Error handling

Every helper exposes failures as an error: Signal<Error | undefined> and moves status() to 'error'. Imperative helpers do that and reject their promise, so you can handle a failure declaratively in the template, imperatively at the call site, or both.

A query has no call site, so its only failure channel is reactive:

readonly todos = injectQuery(api.todos.list, () => ({ count: 20 }), {
onError: (err) => console.error(err),
});
Surface Behavior on failure
error() Holds the Error. Cleared when a later result succeeds.
status() 'error'.
data() The last real value is preserved, so the UI does not blank out.
onError Called with the Error.

mutate() and run() update the reactive state and then reject. The rejection carries the same Error instance that lands on error().

readonly completeTodo = injectMutation(api.todos.complete);
async complete(id: TodoId) {
await this.completeTodo.mutate({ id }); // rejects on failure
}

Because the promise rejects, an unhandled call produces an unhandled rejection. Choose a channel deliberately:

  • Reactive only — render error() in the template and swallow the rejection at the call site with try/catch, or void promise.catch(() => {}).
  • Imperative onlycatch and handle it, ignoring the signal.
  • Both — the common case: catch for control flow (stay on the form, focus a field) and error() for display.
Surface Behavior on failure
error() Holds the Error.
status() 'error'.
data() Cleared at the start of the call and not repopulated.
onError Called with the Error, before the promise rejects.
the promise Rejects with the same Error instance.

reset() clears data, error, and returns the status to 'idle' — useful after navigating away from a failed form.

Convex functions can throw anything. Imperative helpers normalize whatever comes back before it touches your code:

const errorObj = err instanceof Error ? err : new Error(String(err));

So error() is always an Error (never a string, null, or a bare object), onError always receives an Error, and the promise always rejects with an Error. A thrown 'boom' becomes new Error('boom'); the original value survives only as the message text.

Convex distinguishes an application error — thrown deliberately by your function with new ConvexError(payload) — from a transport or unexpected error. ConvexError extends Error and carries the payload on .data.

convex-angular re-exports ConvexError so you can narrow helper errors without reaching into convex/values:

import { ConvexError } from 'convex-angular';
convex/todos.ts
import { ConvexError } from 'convex/values';
export const complete = mutation({
args: { id: v.id('todos') },
handler: async (ctx, args) => {
const todo = await ctx.db.get(args.id);
if (!todo) {
throw new ConvexError({ code: 'NOT_FOUND', id: args.id });
}
await ctx.db.patch(args.id, { completed: true });
return null;
},
});
todo-errors.component.ts
import { Component, computed } from '@angular/core';
import { ConvexError, injectMutation, injectQuery } from 'convex-angular';
import { TodoId, api } from './convex/api';
@Component({
selector: 'app-todo-errors',
template: `
@if (todos.error(); as error) {
<p role="alert">{{ error.message }}</p>
}
@if (validationMessage(); as message) {
<p role="alert">{{ message }}</p>
}
`,
})
export class TodoErrorsComponent {
readonly todos = injectQuery(api.todos.list, () => ({ count: 20 }));
readonly completeTodo = injectMutation(api.todos.complete);
// `ConvexError` is re-exported by convex-angular so you can narrow helper
// errors without importing from `convex/values` directly.
readonly validationMessage = computed(() => {
const error = this.todos.error();
return error instanceof ConvexError ? String(error.data) : undefined;
});
async complete(id: TodoId): Promise<void> {
try {
// The rejection and `completeTodo.error()` carry the same Error instance.
await this.completeTodo.mutate({ id });
} catch (error) {
if (error instanceof ConvexError) {
// Application error thrown by your Convex function: `.data` is the
// payload you passed to `new ConvexError(...)`.
console.error('rejected by the backend', error.data);
} else if (error instanceof Error) {
// Anything else — including a non-Error throw, which the helper
// normalizes to `new Error(String(err))`.
console.error(error.message);
}
}
}
}

instanceof ConvexError is the only reliable narrowing — do not parse error.message. An error that is not a ConvexError is a transport failure, an argument-validation failure, or an uncaught exception in your function, and should generally be reported rather than shown verbatim to the user.

  • Status and state — how 'error' is derived and what it hides.
  • Reactivity — generation and version guarding of stale failures.