Status and state
Every helper exposes a single status() Signal alongside its narrower boolean Signals. status() is
the value to branch on in templates: it is a string union, so @switch over it is exhaustive and
type-checked.
The unions
Section titled “The unions”type QueryStatus = 'pending' | 'success' | 'error' | 'skipped';type PaginatedQueryStatus = 'pending' | 'success' | 'error' | 'skipped';
type MutationStatus = 'idle' | 'pending' | 'success' | 'error';type ActionStatus = 'idle' | 'pending' | 'success' | 'error';Queries have no 'idle' — a query subscribes as soon as it exists, so there is no “not started yet”.
Mutations and actions have no 'skipped' — you simply do not call them.
| Value | Queries | Mutations and actions |
|---|---|---|
'idle' |
— | Not called yet, or reset() was called. |
'pending' |
Loading initial data, or resubscribing. | The call is in flight. |
'success' |
A result was received. | The last call completed successfully. |
'error' |
The subscription failed. | The last call failed. |
'skipped' |
The args function returned skipToken. |
— |
Derivations
Section titled “Derivations”These are the exact formulas, in evaluation order.
injectQuery()
Section titled “injectQuery()”isSuccess = !isLoading() && !isSkipped() && !error();isRefetching = isLoading() && !isPlaceholderData() && data() !== undefined;
status = isSkipped() ? 'skipped' : isLoading() ? 'pending' : error() ? 'error' : 'success';isLoading and isSkipped are written directly by the subscription effect; data, error, and
isPlaceholderData are written by the settle/fail callbacks. Their initial values are false,
false, undefined, undefined, and false.
injectPaginatedQuery()
Section titled “injectPaginatedQuery()”isSuccess = !isLoadingFirstPage() && !isSkipped() && !error();
status = isSkipped() ? 'skipped' : isLoadingFirstPage() ? 'pending' : error() ? 'error' : 'success';isLoadingMore is deliberately not part of status: loading a later page keeps the status at
'success' so the already-rendered pages stay on screen. Branch on status() for the page shell and
on isLoadingMore() for the “load more” affordance.
injectMutation() and injectAction()
Section titled “injectMutation() and injectAction()”Both are built on the same internal state:
isSuccess = hasCompleted() && !isLoading() && !error();
status = isLoading() ? 'pending' : error() ? 'error' : hasCompleted() ? 'success' : 'idle';hasCompleted is set on every settling path, success and failure alike — the status derivation
just checks error() first, so a failed call reads 'error'. reset() clears data, error,
isLoading, and hasCompleted, returning the status to 'idle'.
The pre-change-detection gotcha
Section titled “The pre-change-detection gotcha”The cause is mechanical. injectQuery() initializes isLoading to false and only sets it to
true inside its subscription effect(). Angular does not run that effect at creation time — it
runs during the first change detection pass. In the window between construction and that pass:
| Signal | Value |
|---|---|
isSkipped() |
false |
isLoading() |
false |
error() |
undefined |
data() |
undefined |
status() |
'success' |
Anything that reads the query’s state before the first change detection sees this — a computed()
consumed eagerly, a synchronous read in a constructor, an assertion in a unit test that never calls
fixture.detectChanges(). In a normal template it is transient, but a template that assumes
'success' implies data will still throw on the first render pass if it dereferences the value.
Write the success branch so it binds the data rather than assuming it:
import { Component, signal } from '@angular/core';import { injectQuery } from 'convex-angular';
import { api } from './convex/api';
@Component({ selector: 'app-safe-todo-list', template: ` @switch (todos.status()) { @case ('pending') { <p>Loading…</p> } @case ('skipped') { <p>Nothing selected.</p> } @case ('error') { <p role="alert">{{ todos.error()?.message }}</p> } @case ('success') { <!-- Never assume 'success' implies data(): before the first change detection the subscription effect has not run yet, so status() is 'success' while data() is still undefined. Bind the value. --> @if (todos.data(); as list) { @for (todo of list; track todo._id) { <p>{{ todo.title }}</p> } } @else { <p>Loading…</p> } } } `,})export class SafeTodoListComponent { readonly count = signal(20); readonly todos = injectQuery(api.todos.list, () => ({ count: this.count() }));}@for (todo of todos.data() ?? []; track todo._id) is equally safe, and todos.data()?.title works
for a single-object query. What is not safe is todos.data()!.title or todos.data().length.
Choosing between the signals
Section titled “Choosing between the signals”status()— the one value to@switchon for the overall shape of the UI.isLoading()— true for both the initial load and a resubscribe. Good for disabling a control.isRefetching()— true only when a previous real value is still on screen while a new one loads. It is false during the initial load and while placeholder data is shown, which is exactly what you want to distinguish a full skeleton from a subtle “refreshing” indicator.isPlaceholderData()— true whiledata()holdsplaceholderDatarather than a real result. The status stays'pending'in that case, andonSuccessdoes not fire.isSkipped()— equivalent tostatus() === 'skipped'.isSuccess()— equivalent tostatus() === 'success', and carries the same caveat above.
Related
Section titled “Related”- Reactivity — what causes the transitions between these states.
- Error handling — the
'error'status in detail.