Conditional queries with skipToken
Some queries only make sense once something else is known — a selected row, a route
parameter, a signed-in user. skipToken expresses that: return it from the argument function
and no subscription is opened.
export const skipToken: unique symbol = Symbol('skipToken');export type SkipToken = typeof skipToken;It is a unique symbol, so the argument function’s return type is
Query['_args'] | SkipToken and TypeScript narrows correctly on either branch.
import { Component, signal } from '@angular/core';import { injectQuery, skipToken } from 'convex-angular';
import { api, type TodoId } from './convex/api';
@Component({ selector: 'app-todo-detail', template: ` @switch (todo.status()) { @case ('skipped') { <p>Select a todo to see its details.</p> } @case ('pending') { <p>Loading…</p> } @case ('error') { <p role="alert">{{ todo.error()?.message }}</p> } @case ('success') { <h2>{{ todo.data()?.title }}</h2> <p>{{ todo.data()?.description }}</p> } } `,})export class TodoDetailComponent { readonly selectedId = signal<TodoId | null>(null);
// The condition lives *inside* the argument function. Never wrap the // `injectQuery` call itself in an `@if` — helpers must be created // unconditionally in an injection context. readonly todo = injectQuery(api.todos.get, () => { const id = this.selectedId(); return id === null ? skipToken : { id }; });
select(id: TodoId | null): void { this.selectedId.set(id); }}What happens when a query is skipped
Section titled “What happens when a query is skipped”The moment the argument function returns skipToken, the helper:
- tears down the active subscription (if any),
- sets
data()toundefined, - sets
error()toundefined, - sets
isLoading()tofalse, - sets
isSkipped()totrue, - sets
isPlaceholderData()tofalse, - bumps the generation counter, so any in-flight callback from the old subscription is discarded.
The derived signals follow: status() is 'skipped', isSuccess() is false, and
isRefetching() is false (there is no data to preserve).
Handling the skipped state
Section titled “Handling the skipped state”'skipped' is a first-class member of QueryStatus, so a @switch covers it exhaustively:
@switch (todo.status()) { @case ('skipped') {<p>Select a todo.</p>} @case ('pending') {<p>Loading…</p>} @case ('error') {<p role="alert">{{ todo.error()?.message }}</p>} @case ('success') {<p>{{ todo.data()?.title }}</p>} }A skip round trip bypasses args dedup
Section titled “A skip round trip bypasses args dedup”Normally, a re-run of the argument function that produces identical serialized arguments is deduplicated: the live subscription is kept and no round trip happens. Skipping clears the remembered subscription identity, so going out of the skipped state always opens a fresh subscription — even when the arguments are exactly what they were before.
const shouldSkip = signal(false);const todos = injectQuery(api.todos.list, () => (shouldSkip() ? skipToken : { count: 10 }));
shouldSkip.set(true); // unsubscribes, clears datashouldSkip.set(false); // subscribes again, even though args are unchangedThis is usually what you want — the local state was cleared, so it has to be refilled — but it does mean a rapidly toggled condition produces real subscription churn. If a condition flips often and the arguments do not change, prefer leaving the query subscribed and hiding its output in the template.
Other helpers
Section titled “Other helpers”skipToken works the same way in injectPaginatedQuery (results go
back to [], status() becomes 'skipped', loadMore() returns false) and in the
convexQueryResolver, where it resolves undefined
synchronously without touching the network.
injectQueries treats it differently in one important way: a key
assigned skipToken stays present in every record with status 'skipped', whereas a key
removed from the definition disappears entirely.