Skip to content

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.

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 {}.

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.
app.routes.ts
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,
}),
},
},
];

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

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.

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.