Route resolver
convexQueryResolver() builds an Angular ResolveFn that holds navigation until a Convex
query delivers its first result, then keeps that subscription warm for a grace period. It is
the explicit-preloading counterpart to injectQuery — the equivalent of React’s
preloadQuery / usePreloadedQuery flow.
Signature
Section titled “Signature”function convexQueryResolver<Query extends QueryReference>( query: Query, argsFn?: (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => FunctionArgs<Query> | SkipToken, options?: ConvexQueryResolverOptions,): ResolveFn<FunctionReturnType<Query> | undefined>;argsFn maps the route snapshot to query arguments. Omit it and the query is called with
{}.
ConvexQueryResolverOptions
Section titled “ConvexQueryResolverOptions”| Name | Type | Default | Description |
|---|---|---|---|
keepSubscribedFor |
number |
5000 |
Milliseconds to keep the resolver’s subscription alive after the route resolves, so the routed component’s own injectQuery deduplicates onto it. |
import { Component, inject } from '@angular/core';import { toSignal } from '@angular/core/rxjs-interop';import { ActivatedRoute, type ParamMap, type Routes } from '@angular/router';import { convexQueryResolver, injectQuery } from 'convex-angular';
import { api } from './convex/api';
/** A route matched on `:id` always has one, but the type does not say so. */function requireParam(paramMap: ParamMap, name: string): string { const value = paramMap.get(name); if (value === null) { throw new Error(`Missing route parameter: ${name}`); } return value;}
@Component({ selector: 'app-user-profile', template: ` <!-- The warm-cache path keeps status() at 'pending' with isRefetching() true until the live subscription confirms the value, so render the data with a refreshing hint — not a skeleton. --> @if (profile.data(); as user) { <h1>{{ user.name }}</h1> @if (profile.isRefetching()) { <span aria-live="polite">Refreshing…</span> } } @else if (profile.status() === 'error') { <p role="alert">{{ profile.error()?.message }}</p> } @else { <p>Loading…</p> } `,})export class UserProfileComponent { // `route.snapshot` is not a signal: the router reuses this component when only `:id` changes, so a snapshot read // inside the args function would never re-run and the page would keep showing the first user. `paramMap` emits // synchronously, so `requireSync` gives a Signal<ParamMap> without an `undefined` initial value. private readonly params = toSignal(inject(ActivatedRoute).paramMap, { requireSync: true });
// The resolver's value is not read here. The component simply subscribes // to the same query with the same args and hits the warm cache. readonly profile = injectQuery(api.users.getProfile, () => ({ userId: requireParam(this.params(), 'id'), }));}
export const routes: Routes = [ { path: 'users/:id', component: UserProfileComponent, resolve: { profile: convexQueryResolver(api.users.getProfile, (route) => ({ userId: requireParam(route.paramMap, 'id') }), { keepSubscribedFor: 10_000, }), }, },];How it is meant to be consumed
Section titled “How it is meant to be consumed”Reading route.snapshot.data['profile'] would work, but it gives you a static value that
never updates and forfeits the whole point of Convex. Resolve for the timing, subscribe for
the data.
Read route parameters in the component through a signal, as the example does with toSignal(route.paramMap), not through route.snapshot: when only a parameter changes the router reuses the component, and a snapshot read never re-runs the args function, so the component would keep showing the previous record.
keepSubscribedFor exists to cover the gap between the router resolving and the component’s
effect running. The 5 second default is generous; shorten it only if you have a reason. Values
above 2,147,483,647 ms (about 24.8 days), including Infinity, are capped to that limit; the
subscription is still released when the owning injector is destroyed.
The warm-cache render is 'pending', not 'success'
Section titled “The warm-cache render is 'pending', not 'success'”A warm cache hit is a prefill, not a settled result: the value is shown, but the helper stays pending until the Convex client replays the cached value to the new subscription. The values below hold for one macrotask after the component subscribes.
| Signal | Value right after navigation |
|---|---|
data() |
the resolved value, from the warm cache |
status() |
'pending' |
isLoading() |
true |
isRefetching() |
true |
isSuccess() |
false |
onSuccess |
not called yet — the client’s local replay calls it on the next macrotask |
Failures never block navigation
Section titled “Failures never block navigation”Every failure path resolves undefined rather than rejecting, so navigation always proceeds
and the component’s own injectQuery surfaces the real state reactively:
| Situation | Result |
|---|---|
argsFn returns skipToken |
resolves undefined synchronously, no subscription, no network |
| Subscription error | resolves undefined |
| Injector destroyed before a first result | resolves undefined, subscription disposed |
| Disabled client | resolves undefined immediately instead of hanging |
| Server-side rendering | the query is fetched over HTTP through the SSR loader and transferred to the browser; a failed fetch resolves undefined |
The skipToken case is worth calling out: it returns a plain undefined, not a promise, so
a route whose query is conditionally irrelevant costs nothing at all.
When to reach for it
Section titled “When to reach for it”Blocking navigation is a real cost — the user sits on the previous screen with no feedback.
Use the resolver when an empty first paint would be actively wrong (a detail page whose
entire layout depends on the document), and prefer a normal injectQuery with a loading
state everywhere else.
For non-blocking warming driven by user intent, use
injectPrewarmQuery instead.