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) => Query['_args'] | 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 { 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 { private readonly route = inject(ActivatedRoute);
// 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.route.snapshot.paramMap, '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.
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.
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 still waits for the live subscription to confirm it.
| 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 first live emission fires it |
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.