injectQueries
injectQueries() manages a keyed group of subscriptions behind three record-shaped signals.
Reach for it when the set of queries is itself reactive — one query per selected row, per
open tab, per visible chart — or when several related queries should share one aggregate
loading state.
For a fixed handful of unrelated queries, several injectQuery() calls are simpler and give
you better types per query.
Signature
Section titled “Signature”function injectQueries<Definitions extends QueriesDefinition>( definitionsFn: () => Definitions, options?: QueriesOptions,): QueriesResult<Definitions>;interface QueryRequest<Query extends QueryReference = QueryReference> { query: Query; args: Query['_args'];}
type QueriesDefinition = Record<string, QueryRequest<any> | SkipToken>;The definitions function is reactive: it re-runs whenever a signal it reads changes, and the helper reconciles the resulting key set against the live subscriptions.
import { Component, signal } from '@angular/core';import { injectQueries, skipToken, type QueryRequest } from 'convex-angular';
import { api, type TodoId } from './convex/api';
@Component({ selector: 'app-dashboard', template: ` @if (dashboard.isLoading()) { <span aria-live="polite">Loading dashboard…</span> }
@if (dashboard.statuses().profile === 'skipped') { <p>Sign in to see your profile.</p> } @if (dashboard.results().profile; as profile) { <h2>{{ profile.name }}</h2> } @if (dashboard.errors().todos; as error) { <p role="alert">{{ error.message }}</p> }
<!-- A genuinely dynamic key set: dropping an id from pinnedIds removes that key from results, errors, and statuses. --> @for (id of pinnedIds(); track id) { <p>{{ pinned.results()[id]?.title ?? '…' }} ({{ pinned.statuses()[id] }})</p> }
<button type="button" (click)="dashboard.refetch()">Refresh</button> `,})export class DashboardComponent { readonly userId = signal<string | null>(null);
// A fixed key set. `profile` stays present with status 'skipped' while // there is no user — it never disappears from the records. readonly dashboard = injectQueries(() => { const userId = this.userId(); return { todos: { query: api.todos.list, args: { count: 10 } }, profile: userId === null ? skipToken : { query: api.users.getProfile, args: { userId } }, }; });
readonly pinnedIds = signal<TodoId[]>(['todo-1' as TodoId, 'todo-2' as TodoId]);
// A variable key set built from data. Removing an id unsubscribes that // query and deletes the key from every record. readonly pinned = injectQueries( () => Object.fromEntries(this.pinnedIds().map((id) => [id, { query: api.todos.get, args: { id } }])) as Record< string, QueryRequest<typeof api.todos.get> >, { onSuccess: (key, data) => console.log('pinned', key, data), onError: (key, error) => console.error('pinned', key, error), }, );
unpin(id: TodoId): void { this.pinnedIds.update((ids) => ids.filter((pinnedId) => pinnedId !== id)); }}QueriesOptions
Section titled “QueriesOptions”| Name | Type | Default | Description |
|---|---|---|---|
injectRef |
EnvironmentInjector |
ambient injector | Injector used to create the group outside the current injection context. |
onSuccess |
(key: string, data: unknown) => void |
— | Invoked for every real emission, with the definition key it belongs to. |
onError |
(key: string, err: Error) => void |
— | Invoked when a keyed query fails. |
The callbacks are untyped in data because the group is heterogeneous — narrow on key, or
read the typed value from results() instead.
Like injectQuery, onSuccess fires only for
real emissions. Warm-cache seeds and data transferred from a server render do not trigger it.
QueriesResult
Section titled “QueriesResult”| Name | Type | Description |
|---|---|---|
results |
Signal<{ [K in keyof Definitions]: QueryData<Definitions[K]> }> |
Latest value per key. undefined for a key that has not emitted yet and for skipped keys. |
errors |
Signal<{ [K in keyof Definitions]: Error | undefined }> |
Latest error per key. |
statuses |
Signal<{ [K in keyof Definitions]: QueryStatus }> |
'pending' | 'success' | 'error' | 'skipped' per key. |
isLoading |
Signal<boolean> |
True while any key’s status is 'pending'. |
refetch |
() => void |
Resubscribe every active key. |
isLoading is literally Object.values(statuses()).some((s) => s === 'pending'). It is an
“any key pending” aggregate, not “all keys pending” — one slow query keeps the whole group
loading. When that is too coarse, read statuses() per key.
The key distinction: removing a key vs. skipping it
Section titled “The key distinction: removing a key vs. skipping it”This is the single most important thing to get right about injectQueries.
const showProfile = signal(true);
// Removal: `profile` disappears from every record when showProfile() is false.// Build the record explicitly — a conditional object literal would widen the// key to an optional property, which does not satisfy `QueriesDefinition`.const removed = injectQueries(() => { const definitions: Record<string, QueryRequest<typeof api.users.current>> = {}; if (showProfile()) { definitions['profile'] = { query: api.users.current, args: {} }; } return definitions;});
// Skipping: `profile` is always a key; it just reports 'skipped'.const skipped = injectQueries(() => ({ profile: showProfile() ? { query: api.users.current, args: {} } : skipToken,}));| Removed key | skipToken key |
|
|---|---|---|
Present in results() |
no | yes, value undefined |
Present in errors() |
no | yes, value undefined |
Present in statuses() |
no | yes, value 'skipped' |
| Subscription | torn down | torn down |
Contributes to isLoading() |
no | no |
Template read statuses()['profile'] |
undefined |
'skipped' |
Choose by what the template needs to say. A key that is genuinely gone — an unpinned row, a
closed tab — should be removed. A key that is waiting on a precondition — no user selected
yet — should be skipToken, so the template can render “select a user” from
statuses().profile === 'skipped' rather than from a missing property.
Removal also disposes correctly in both directions: a removed key’s subscription is unsubscribed, and a late callback from it cannot resurrect the key in the records.
Per-key reconciliation and stale-work guarding
Section titled “Per-key reconciliation and stale-work guarding”Each re-run of the definitions function reconciles keys independently:
- A key whose query name and serialized args are unchanged keeps its live subscription. Changing one key’s args resubscribes only that key.
- A key whose definition changed is unsubscribed and resubscribed. Its
errors()entry is cleared and itsstatuses()entry goes back to'pending', while its existingresults()entry is left in place. - Callbacks are matched against the subscription object that is currently registered for the key. A result from a replaced subscription is dropped, so a slow response for old arguments can never overwrite a newer one — and a result for a removed key is ignored entirely.
Before the live subscription emits, each key is seeded like a single query: the warm client
cache first (status stays 'pending'), then data transferred from a server render (which
reports 'success' immediately so hydration matches the server HTML).
refetch()
Section titled “refetch()”refetch() bumps a version counter that forces every active key to resubscribe, even those
whose definitions are unchanged. Skipped keys stay skipped.
While the refetch is pending, previous results stay visible but statuses go back to
'pending':
// After data has arrived for both keys:queries.refetch();
queries.statuses(); // { user: 'pending', todos: 'pending' }queries.results().user; // still the previously loaded userqueries.isLoading(); // trueCombine that with an isLoading()-driven affordance rather than an empty state, or the whole
dashboard will blink on every refresh.