Skip to content

Placeholder data

placeholderData fills data() with a provisional value while the first result for the current arguments is still loading. The classic use is a master/detail view: the list row you just clicked already contains most of the detail document, so the detail pane can render instantly instead of flashing a skeleton.

type QueryPlaceholderData<Query extends QueryReference> =
| FunctionReturnType<Query>
| ((args: Query['_args']) => FunctionReturnType<Query> | undefined);

Pass either a plain value or a factory. Convex values are never functions, so a function-typed placeholderData is unambiguously treated as a factory.

todo-master-detail.component.ts
import { Component, signal } from '@angular/core';
import { injectQuery } from 'convex-angular';
import { api, type TodoId } from './convex/api';
@Component({
selector: 'app-todo-master-detail',
template: `
<ul>
@for (todo of list.data() ?? []; track todo._id) {
<li>
<button type="button" (click)="selectedId.set(todo._id)">{{ todo.title }}</button>
</li>
}
</ul>
@if (detail.data(); as todo) {
<!-- The title renders immediately from the list row; the description
only exists on the real document. -->
<h2 [class.is-stale]="detail.isPlaceholderData()">{{ todo.title }}</h2>
@if (detail.isPlaceholderData()) {
<p aria-live="polite">Loading details…</p>
} @else {
<p>{{ todo.description }}</p>
}
}
@if (detail.error(); as error) {
<!-- data() is already cleared here: a placeholder is never shown
next to an error. -->
<p role="alert">{{ error.message }}</p>
}
`,
})
export class TodoMasterDetailComponent {
readonly selectedId = signal<TodoId>('todo-1' as TodoId);
readonly list = injectQuery(api.todos.list, () => ({ count: 20 }));
readonly detail = injectQuery(api.todos.get, () => ({ id: this.selectedId() }), {
// A factory receives the current args. It runs inside `untracked()`, so
// reading `list.data()` here does NOT make the detail subscription
// depend on the list query.
placeholderData: (args) => this.list.data()?.find((todo) => todo._id === args.id),
});
}

This is the whole design. Showing invented data must never be mistaken for having loaded real data:

Signal Value while a placeholder is shown
data() the placeholder value
isPlaceholderData() true
isLoading() true
status() 'pending'
isSuccess() false
isRefetching() false — explicitly excluded by isLoading() && !isPlaceholderData() && data() !== undefined
onSuccess not called

isRefetching() being false is deliberate: it means “a real previous value is on screen while we revalidate”. A placeholder is not a previous value, so a UI gated on isRefetching() correctly shows its first-load treatment.

Event Result
First real emission arrives data() replaced, isPlaceholderData() false, onSuccess fires
Query errors data() set to undefined, isPlaceholderData() false, error() set
Argument function returns skipToken data() cleared, isPlaceholderData() false, status() 'skipped'
Arguments change the old placeholder is dropped and the factory is re-evaluated for the new args

The error case is the one that differs from real data. injectQuery normally preserves existing data when the subscription fails, so the UI does not blank out — but a placeholder is discarded, because invented data sitting next to an error message is worse than no data. Once a placeholder has been replaced by a real value, that real value is preserved on error like any other.

On every resubscribe the helper resolves what to show, in this order:

  1. Warm cache — a locally cached result for the current args.
  2. Transferred data — a value carried over from the server render during hydration.
  3. Preserved previous real value — whatever data() already held, if it was real.
  4. placeholderData — only when none of the above applies.

So the placeholder is a last resort. Navigating back to a detail page whose value is still in the client cache shows the cached document, not the placeholder; and an args change on a query that already has real data keeps that data rather than swapping in a placeholder.

The one nuance in step 3: if what data() held was itself a placeholder, it is not preserved. The factory is re-evaluated against the new args, so a stale placeholder for the previous selection never leaks into the next one. A factory returning undefined means “no placeholder for these args”, and data() becomes undefined.

Placeholder factories are invoked inside untracked(). Signals read inside the factory — the list query’s data(), a store selector, a route parameter — do not become dependencies of the subscription effect.

Note the consequence: the factory is only re-evaluated when the subscription effect re-runs (an args change, a refetch(), or a skip round trip). It does not re-run when the signals it reads change.