Skip to content

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.

todo-detail.component.ts
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);
}
}

The moment the argument function returns skipToken, the helper:

  1. tears down the active subscription (if any),
  2. sets data() to undefined,
  3. sets error() to undefined,
  4. sets isLoading() to false,
  5. sets isSkipped() to true,
  6. sets isPlaceholderData() to false,
  7. 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).

'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>
} }

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 data
shouldSkip.set(false); // subscribes again, even though args are unchanged

This 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.

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.