Reactivity
Query helpers do not take an arguments object — they take an arguments function. That function
runs inside an Angular effect(), so any Signal it reads becomes a dependency of the subscription.
readonly count = signal(20);readonly todos = injectQuery(api.todos.list, () => ({ count: this.count() }));Setting count to a new value re-runs the effect, which tears down the old subscription and opens a
new one for the new arguments.
import { Component, signal } from '@angular/core';import { injectQuery, skipToken } from 'convex-angular';
import { api } from './convex/api';
@Component({ selector: 'app-category-todos', template: ` <button (click)="todos.refetch()">Refetch</button> @if (todos.isRefetching()) { <span>Refreshing…</span> } `,})export class CategoryTodosComponent { readonly category = signal<string | null>(null);
readonly todos = injectQuery(api.todos.listByCategory, () => { const category = this.category(); // Returning `skipToken` tears down the subscription and moves the query to // the 'skipped' status instead of calling the backend with a null value. return category === null ? skipToken : { category }; });
// Setting the same value produces a structurally identical args object. // The subscription key is the serialized args, so this does NOT resubscribe. keepSameCategory(): void { this.category.set(this.category()); }}Subscription identity is the serialized args
Section titled “Subscription identity is the serialized args”Re-running the effect does not by itself resubscribe. The helper computes a subscription key and skips the work when the key has not changed:
key = `${refetchVersion}:${JSON.stringify(convexToJson(args))}`Two consequences follow directly:
- A new object with identical content does not resubscribe.
{ count: 20 }created fresh on every effect run serializes to the same string as the previous one, so the live subscription is kept open. Structural identity is what matters, not reference identity — you never needcomputed()memoization or a custom equality function to avoid churn. - Key order matters, because
JSON.stringifypreserves it.{ a: 1, b: 2 }and{ b: 2, a: 1 }serialize differently and therefore count as different subscriptions. Build args objects with a stable key order; literal object expressions in source naturally have one.
Arguments are encoded with convexToJson before stringifying, so Convex value types (Id, Bytes,
Int64, …) round-trip to a stable representation rather than to {}.
What is tracked, and what is not
Section titled “What is tracked, and what is not”| Input | Reactive? |
|---|---|
| Signals read inside the args fn | Yes — tracked, and resubscribe when the serialized args change. |
refetch() |
Yes — bumps an internal version signal, changing the subscription key. |
The function reference (api.x.y) |
No — captured once. |
options.onSuccess / onError |
No — captured once. |
options.placeholderData |
No — captured once. Factories run untracked, so signals read inside one do not retrigger the subscription. |
options.injectRef |
No — captured once, and it also changes ownership. See Injection context. |
If a query genuinely needs different callbacks over time, keep the callback stable and read the changing part from a signal inside it.
refetch() and skipToken
Section titled “refetch() and skipToken”refetch() increments a version signal. Because the version is part of the subscription key, the
key changes even when the args are byte-identical, forcing a real teardown and resubscribe. The
previous value stays in data() while the new subscription settles, so the UI does not flash empty —
isRefetching() is true during that window.
Returning skipToken from the args function takes a different path entirely: it clears data and
error, sets isSkipped, tears down the subscription, and forgets the subscription key. The next
non-skip args value therefore always opens a fresh subscription.
Stale asynchronous work is discarded
Section titled “Stale asynchronous work is discarded”Every subscription run takes the next value of an internal generation counter. Both the success and failure callbacks compare their captured generation against the current one and return early if it no longer matches.
That means a slow response for old arguments can never overwrite a newer result — a real risk when a user types quickly into a filter, or when a server-side fetch resolves after the args have moved on. The same guard applies during teardown: destroying the scope bumps the generation, so a callback already in flight becomes a no-op.
Mutations and actions use the equivalent mechanism with a version counter: only the most recent
invocation may write data, error, or isLoading, and only it fires onSuccess/onError.
reset() also bumps that version, so an in-flight call that resolves after a reset is ignored.
Cleanup
Section titled “Cleanup”Every helper registers its teardown on the owning scope’s DestroyRef:
- Query helpers unsubscribe from the live subscription and bump the generation counter.
- Mutation and action helpers mark themselves destroyed and reset their state. A
mutate()/run()promise issued before destruction still settles — the underlying call is not cancelled — but it no longer writes reactive state and no longer fires callbacks. provideConvex()closes theConvexClientwhen its injector is destroyed.
You never call an unsubscribe function by hand.
Related
Section titled “Related”- Status and state — what the signals read during these transitions.
- Error handling — where failures from a subscription surface.