injectQuery
injectQuery() opens a live subscription to one Convex query and exposes its state as
readonly signals. The subscription is reactive: every signal read inside the argument
function is tracked, and the query resubscribes when those arguments change.
Signature
Section titled “Signature”function injectQuery<Query extends QueryReference>( query: Query, argsFn: () => Query['_args'] | SkipToken, options?: QueryOptions<Query>,): QueryResult<Query>;QueryReference is FunctionReference<'query'> — any query from your generated api.
Call it in an injection context (a component field initializer, a service constructor, or
inside runInInjectionContext). To create it elsewhere, pass an
injectRef.
import { Component, signal } from '@angular/core';import { ConvexError, injectQuery } from 'convex-angular';
import { api } from './convex/api';
@Component({ selector: 'app-todo-list', template: ` <label> Page size <input type="number" [value]="count()" (input)="count.set(+$any($event.target).value)" /> </label>
<!-- isRefetching() is true only while a previous value is still on screen, so it drives a subtle affordance rather than a skeleton. --> @if (todos.isRefetching()) { <span aria-live="polite">Refreshing…</span> }
@switch (todos.status()) { @case ('pending') { @if (!todos.isRefetching()) { <p>Loading…</p> } } @case ('error') { <p role="alert">{{ todos.error()?.message }}</p> <button type="button" (click)="todos.refetch()">Try again</button> } @case ('success') { <ul> @for (todo of todos.data() ?? []; track todo._id) { <li>{{ todo.title }}</li> } </ul> } } `,})export class TodoListComponent { readonly count = signal(20);
// Every signal read inside the argument function is tracked. Changing // `count` re-runs it, and because the serialized args differ the helper // tears down the old subscription and opens a new one. `data()` keeps the // previous page until the new one arrives. readonly todos = injectQuery(api.todos.list, () => ({ count: this.count() }), { // Fires for real emissions only — never for a warm-cache seed, data // transferred from a server render, or `placeholderData`. onSuccess: (todos) => console.log('loaded', todos.length, 'todos'), onError: (error) => { // Errors thrown by the Convex function with `ConvexError` carry a // typed payload. if (error instanceof ConvexError) { console.error(error.data); } }, });}QueryOptions
Section titled “QueryOptions”| Name | Type | Default | Description |
|---|---|---|---|
injectRef |
EnvironmentInjector |
ambient injector | Injector used to create the query outside the current injection context. Its destruction also tears down the subscription. |
onSuccess |
(data: FunctionReturnType<Query>) => void |
— | Invoked for every real emission of the subscription, including the one-shot fetch during server-side rendering. |
onError |
(err: Error) => void |
— | Invoked when the subscription fails. |
placeholderData |
FunctionReturnType<Query> | ((args: Query['_args']) => FunctionReturnType<Query> | undefined) |
— | Value shown in data() while the first result for the current args loads. See Placeholder data. |
When onSuccess fires
Section titled “When onSuccess fires”onSuccess is a subscription callback, not a “data became available” callback. It runs only
when the Convex client actually emits a value (or the SSR one-shot fetch resolves). It does
not run for data that merely seeds the pending state:
- a warm client-cache hit for the current args,
- data transferred from a server render during hydration,
placeholderData.
In each of those cases the value lands in data() immediately and the first live emission
after the WebSocket syncs is what fires onSuccess.
QueryResult
Section titled “QueryResult”| Name | Type | Description |
|---|---|---|
data |
Signal<FunctionReturnType<Query> | undefined> |
Latest value. undefined until something is available; undefined while skipped. Preserved across resubscribes. |
error |
Signal<Error | undefined> |
Latest subscription error, cleared on the next successful emission. |
isLoading |
Signal<boolean> |
True while waiting for a result — initial load, args-change resubscribe, or refetch(). |
isRefetching |
Signal<boolean> |
True while resubscribing with a previous real value still on screen. |
isPlaceholderData |
Signal<boolean> |
True while data() holds placeholderData rather than a real value. |
isSkipped |
Signal<boolean> |
True when the argument function returned skipToken. |
isSuccess |
Signal<boolean> |
True when a value has been received and the query is neither loading, skipped, nor errored. |
status |
Signal<QueryStatus> |
'pending' | 'success' | 'error' | 'skipped'. |
refetch |
() => void |
Force a resubscribe. |
Derivations
Section titled “Derivations”Three of the signals are computed, so their exact semantics fall out of these formulas:
isSuccess = !isLoading() && !isSkipped() && !error();
isRefetching = isLoading() && !isPlaceholderData() && data() !== undefined;
status = isSkipped() ? 'skipped' : isLoading() ? 'pending' : error() ? 'error' : 'success';Two consequences are worth internalizing:
status()reports'pending'before it reports'error'. A resubscribe after a failure moves the status back to'pending'even thougherror()is still set.- Warm-cache and preserved values leave
status()at'pending'whiledata()is already populated. That combination is exactly whatisRefetching()identifies — render a refreshing affordance instead of a skeleton.
Reactive arguments
Section titled “Reactive arguments”The argument function runs inside an effect. When it re-runs, the helper serializes the returned args and compares them against the current subscription’s identity:
- Different args — the old subscription is torn down and a new one opened.
- Identical serialized args — the live subscription is kept. A signal change that does not change the arguments never causes a resubscribe.
Stale work is guarded by a generation counter, so callbacks from a replaced subscription are ignored even if they arrive late.
data survives a resubscribe
Section titled “data survives a resubscribe”An args change does not blank the UI. On resubscribe the helper looks for something local to show, in this order:
- the Convex client’s warm cache for the new args,
- data transferred from the server render (hydration only — this one reports success immediately so the hydrated DOM matches the server HTML),
- the previous real value, left in place while the new subscription loads,
- otherwise
placeholderData.
Only cases 1, 3, and 4 keep status() at 'pending'. In cases 1 and 3 isRefetching() is
true.
refetch()
Section titled “refetch()”refetch() bumps an internal version counter, which changes the subscription identity and
forces a teardown/resubscribe even when the arguments serialize identically. It is the escape
hatch for the args-dedup behavior above.
Existing data is preserved while the refetch is pending, so the sequence for a query that
already has data is: isLoading() goes true, status() returns to 'pending',
isRefetching() becomes true, data() stays put, and the next emission settles everything.
Errors
Section titled “Errors”error() holds the thrown Error. Real data is preserved alongside it so the UI does
not blank out; placeholder data is cleared instead (see
Placeholder data). Application errors thrown by your Convex
function with ConvexError carry a typed payload:
if (todos.error() instanceof ConvexError) { console.error((todos.error() as ConvexError<{ code: string }>).data.code);}ConvexError is re-exported from convex-angular.
Related
Section titled “Related”- Conditional queries with
skipToken - Placeholder data
injectQueriesfor a dynamic keyed group- Prewarming and the route resolver for warming the cache before a component mounts