Error handling
Every helper exposes failures as an error: Signal<Error | undefined> and moves status() to
'error', with one exception: a failed page of injectPaginatedQuery() in the browser is not reported. 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.
Queries
Section titled “Queries”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. |
Mutations and actions
Section titled “Mutations and actions”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 withtry/catch, orvoid promise.catch(() => {}). - Imperative only —
catchand handle it, ignoring the signal. - Both — the common case:
catchfor control flow (stay on the form, focus a field) anderror()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.
Non-Error throws are normalized
Section titled “Non-Error throws are normalized”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.
Narrowing with ConvexError
Section titled “Narrowing with ConvexError”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';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; },});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.
Errors thrown by your callbacks
Section titled “Errors thrown by your callbacks”An exception thrown from onSuccess or onError, on any query, mutation or action helper, is
passed to Angular’s ErrorHandler. It does not change data(), error() or status(), it does
not make mutate() or run() reject, and it does not stop other subscribers from receiving the
update. Handle failures inside the callback if you need to react to them.
Related
Section titled “Related”- Status and state — how
'error'is derived and what it hides. - Reactivity — generation and version guarding of stale failures.