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.
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), });}A placeholder is never a success
Section titled “A placeholder is never a success”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.
When a placeholder is cleared
Section titled “When a placeholder is cleared”| 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.
Precedence: real local data always wins
Section titled “Precedence: real local data always wins”On every resubscribe the helper resolves what to show, in this order:
- Warm cache — a locally cached result for the current args.
- Transferred data — a value carried over from the server render during hydration.
- Preserved previous real value — whatever
data()already held, if it was real. 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.
Factories run untracked
Section titled “Factories run untracked”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.