Introduction
Angular has offered route resolvers for a long time. They work, but every navigation charges you for them: they execute sequentially from parent to child, and refreshing them requires kicking off a whole new navigation. Angular now provides an option built on Signals, surfaced on the router as the resources route property. The official documentation already explains how to use it: https://next.angular.dev/guide/routing/data-fetching-with-resources.
What that documentation leaves out is the machinery between writing resource({...}) in your route config and your component receiving a value. A whole file exists for that: router_resource.ts.
This article covers what I found in it (expect a deeply technical read): how blocking behaves, why the UI doesn't flash a loading skeleton when you move between two similar pages, and how a cancelled navigation can leave you stuck if the reload guard isn't something you understand.
I'm assuming you already know what a resource() is — value(), isLoading(), error(), status(), and reload(), all signals. If that's not the case, start with the Resource API guide, since everything here builds on it.
Turning it on
Route resources sit behind a feature flag you must enable explicitly, withRouterResources():
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withComponentInputBinding, withRouterResources } from '@angular/router';
import { routes } from './app.routes';
import { App } from './app';
bootstrapApplication(App, {
providers: [
provideRouter(routes, withComponentInputBinding(), withRouterResources()),
],
});
Look at withComponentInputBinding() sitting beside it. Resources don't require it, but omit it and instead of receiving your data as a regular component input(), you'll be reaching into ActivatedRoute.resources by hand. Realistically, you'll want both enabled together.
Defining resources on a route
A route now takes a resources property: a function that returns a map of Resource instances.
import { inject } from '@angular/core';
import { resource } from '@angular/core';
import { Routes } from '@angular/router';
import { ProductService } from './product.service';
export const routes: Routes = [
{
path: 'products/:sku',
component: ProductPage,
resources: (ctx) => {
const productService = inject(ProductService);
return {
product: resource({
params: () => ctx.params()['sku'],
loader: ({ params: sku }) => productService.getBySku(sku),
}),
};
},
},
];
The ctx parameter here is a ResourceContext exposing params, queryParams, data, and a static snapshot, all as signals. The resources function runs inside an injection context too, which is exactly why inject(ProductService) can sit right at the top with no factory wrapper.
One detail will bite you if you overlook it: hand params the primitive you actually care about, not the enclosing object. Writing params: () => ctx.params() seems fine, except the router builds a brand-new params object on each navigation, so the resource observes a different reference every time and reloads even when sku stayed the same. Using params: () => ctx.params()['sku'] avoids this, since the resource then tracks only the primitive value.
You can make resources async as well, when a dynamic import has to happen before setup:
resources: async (ctx) => {
const { fetchProductBySku } = await import('./product-api');
return {
product: resource({
params: () => ctx.params()['sku'],
loader: ({ params: sku }) => fetchProductBySku(sku),
}),
};
},
The router accepts any Resource implementation, not merely resource(). Teams still invested in RxJS can use rxResource() in the same slot, replacing loader with stream.
import { rxResource } from '@angular/core/rxjs-interop';
resources: (ctx) => ({
product: rxResource({
params: () => ctx.params()['sku'],
stream: ({ params: sku }) => productService.getBySku$(sku),
}),
}),
Blocking vs non-blocking, and what actually gets bound to your input
Every resource blocks by default: the router won't activate the route until it settles. That yields a pleasant side effect for your component — the bound input's type is the resolved value T, not Resource<T>, because loading and error states never reach your component; the router dealt with them earlier.
To keep navigation from waiting, wrap the resource in nonBlocking().
import { Resource, resource } from '@angular/core';
import { Routes, nonBlocking } from '@angular/router';
export const routes: Routes = [
{
path: 'products/:sku',
component: ProductPage,
resources: (ctx) => ({
product: nonBlocking(
resource({
params: () => ctx.params()['sku'],
loader: ({ params: sku }) => productService.getBySku(sku),
}),
),
reviews: resource({
params: () => ctx.params()['sku'],
loader: ({ params: sku }) => reviewsService.getForProduct(sku),
}),
}),
},
];
In that config product won't hold up navigation while reviews will. The component must now cover both shapes simultaneously:
@Component({
template: `
@if (product().isLoading()) {
<p>Loading product…</p>
} @else if (product().hasValue()) {
<h1>{{ product().value().name }}</h1>
}
<app-reviews [reviews]="reviews" />
`,
})
export class ProductPage {
product = input.required<Resource<Product>>();
reviews = input.required<Review[]>();
}
nonBlocking() is itself unremarkable — it stamps the resource with an internal symbol, telling the router to leave the wrapped Resource<T> object intact rather than unwrap it.
export function nonBlocking<T, R extends Resource<T>>(res: R): R {
(res as unknown as InternalRouterResource<T>)[BLOCKING_SYMBOL] = false;
return res;
}
The unwrapping that blocking resources get takes place in createResourceOutletBindingEffects, in the same file. It reads the component's declared inputs using reflectComponentType, and for each input name that both matches a key in route.resources and lacks an explicit BLOCKING_SYMBOL of false, it registers an effect() that calls componentRef.setInput(templateName, resource.value()):
for (const {templateName} of mirror.inputs) {
const resource = route.resources?.[templateName];
if (!resource || !(resource as InternalRouterResource)[BLOCKING_SYMBOL]) {
continue;
}
const effectRef = effect(() => {
componentRef.setInput(templateName, resource.value());
}, {injector: componentRef.injector});
createdEffects.push(effectRef);
handledKeys.push(templateName);
}
Two points deserve attention. First, the matching runs on input name against resource map key, so product: resource({...}) only reaches an @Input() (or input()) that is literally called product.
Rename either side without the other and the binding dies quietly — nothing at compile time connects the two.
Second, handledKeys is handed back to the caller, letting the router's usual data-binding subscription (RoutedComponentInputBinder) skip those keys. Non-blocking resources never make it into handledKeys; they flow through the standard data stream instead, bound as the complete Resource object.
What routerResource() actually wraps
Anything placed in the resources map is routed through routerResource() internally before the router consumes it. This is where the interesting work happens:
export function routerResource<T>(source: Resource<T>): Resource<T> & {reload(): boolean} {
const injector = inject(Injector);
const router = injector.get(Router);
const {snapshot: snapshotSignal, frozenSnapshot} = createTransactionalSnapshot(source, router, injector);
const res = resourceFromSnapshots(snapshotSignal) as unknown as InternalRouterResource<T>;
res[SOURCE_RESOURCE_SYMBOL] = source;
res[BLOCKING_SYMBOL] = (source as unknown as InternalRouterResource<T>)[BLOCKING_SYMBOL] !== false;
// ...reload wiring omitted here, see below
return res;
}
Your loader, your params function and your original resource are left untouched. The wrapper reads the resource's snapshot() signal — a ResourceSnapshot, the internal shape that carries status, value and error in one object — via createTransactionalSnapshot, then builds a fresh Resource out of that snapshot stream with resourceFromSnapshots. The original resource survives under SOURCE_RESOURCE_SYMBOL for one reason only: so reload() has something to delegate to afterwards. What your component receives from the router is always this wrapper, never the resource instance you wrote.
The frozen snapshot: how the router avoids a loading flash on navigation
This is the piece I found genuinely clever. Moving from /products/1 to /products/2 resolves both routes to the same component and the same product resource key, differing only in the sku param. Taken naively, that resource would flip straight to isLoading() the instant the param changed, and the component would paint a loading skeleton for a split second before the new product arrived. The router sidesteps this by freezing the resource snapshot for the whole duration of the navigation.
createTransactionalSnapshot subscribes to router.events and handles five event types:
const sub = router.events.subscribe((e) => {
if (e instanceof NavigationStart) {
isRollbackRecoveryPending.set(false);
if (frozenSnapshot() === null) {
frozenSnapshot.set(source.snapshot());
}
} else if (e instanceof NavigationEnd) {
frozenSnapshot.set(null);
isRollbackRecoveryPending.set(false);
} else if (e instanceof NavigationSkipped) {
if (frozenSnapshot() !== null) {
isRollbackRecoveryPending.set(true);
}
} else if (e instanceof NavigationCancel || e instanceof NavigationError) {
const isRollback =
e instanceof NavigationError ||
(e instanceof NavigationCancel &&
e.code !== NavigationCancellationCode.SupersededByNewNavigation &&
e.code !== NavigationCancellationCode.Redirect);
if (!isRollback) return;
isRollbackRecoveryPending.set(true);
}
});
The exposed snapshot signal is nothing more than:
snapshot: computed(() => frozenSnapshot() ?? source.snapshot()),
So a plain successful navigation unfolds like this: NavigationStart fires, the router captures whatever the source resource's snapshot happens to hold at that moment — the old product, already resolved — and pins it in frozenSnapshot. From there, despite the underlying resource already loading the new sku in the background, the component keeps rendering the frozen, stale value. No isLoading flicker, no collapse into an empty state. Then NavigationEnd fires, the freeze releases, frozenSnapshot returns to null, and the computed falls through to source.snapshot(), which by now is holding the new product.
There's a subtlety tucked into the if (frozenSnapshot() === null) guard on NavigationStart. Should a second navigation begin before the first has settled — clicking quickly between two product cards — the router won't stack a freeze on top of an existing freeze. It holds onto the snapshot taken before the first navigation started. This is deliberate: it stops the router from freezing an intermediate value that is already stale.
Rollback recovery: the part almost nobody thinks about
Here's a case the guide touches on only in passing, yet the source handles it explicitly. Suppose a canActivate guard turns down a navigation to /products/2. The router cancels it and rolls the URL and route params back to /products/1. That rollback is itself a change to the params signal your resource depends on — from 2 back to 1 — so your resource, behaving exactly as designed, kicks off a fresh load for sku: 1: data it already held and had displayed moments earlier.
Left unhandled, the user would see the current product freeze briefly (NavigationStart), watch the guard reject the move, then get a loading flash for the very product already on screen. isRollbackRecoveryPending exists to prevent precisely that:
} else if (e instanceof NavigationCancel || e instanceof NavigationError) {
const isRollback =
e instanceof NavigationError ||
(e instanceof NavigationCancel &&
e.code !== NavigationCancellationCode.SupersededByNewNavigation &&
e.code !== NavigationCancellationCode.Redirect);
if (!isRollback) return;
isRollbackRecoveryPending.set(true);
}
Cancellations caused by a newer navigation replacing this one, or by an internal redirect, don't count as a rollback — in those cases the frozen snapshot is released in the normal way through the superseding navigation. Everything else, guard rejections and NavigationError, is treated as a rollback, and the frozen snapshot stays alive until the resource has settled once more:
effect(() => {
if (isRollbackRecoveryPending() && !source.isLoading()) {
isRollbackRecoveryPending.set(false);
frozenSnapshot.set(null);
}
}, {injector});
The freeze lifts only when source.isLoading() is false again, which for the rollback path happens as soon as the resource has re-fetched (or re-resolved instantly, if cached) the previous param value. From the user's point of view nothing happened at all — the product they were viewing never changed — even though a complete reload was quietly triggered and absorbed underneath.
Why reload() sometimes just returns false
Route resources expose .reload(), but it isn't a straight passthrough to your original resource's .reload(). There's a guard in the way:
res.reload = function (): boolean {
if (frozenSnapshot() !== null) {
return false;
}
return (source as any).reload();
};
Call .reload() while a navigation is pending, or while the router is mid-rollback-recovery, and the call is refused — it hands back false and your original resource's reload() never runs. The reasoning follows from everything above: during those windows the router is already steering the resource's lifecycle, and a manual reload slipping in would collide with that transition tracking, potentially unfreezing something the router is intentionally holding frozen. And if your source resource has no reload at all — a custom Resource exposing only loader, for instance — then res.reload is simply () => false, unconditionally.
Worth watching for in your own code. Wire a refresh button to userResource?.reload() and see nothing happen, and the boolean return value is spelling out the reason — it's just easy to overlook a boolean you never check.
@Component({
template: `<button (click)="refresh()">Refresh</button>`,
})
export class ProductPage {
private productResource = inject(ActivatedRoute).resources?.['product'];
refresh() {
const didReload = this.productResource?.reload();
if (!didReload) {
console.warn('Reload skipped, a navigation transition is in progress.');
}
}
}
Triggering a redirect from a loader
A blocking resource is able to redirect before the route activates at all, simply by throwing a RedirectCommand:
import { inject, resource } from '@angular/core';
import { RedirectCommand, Router } from '@angular/router';
resources: (ctx) => {
const router = inject(Router);
return {
product: resource({
params: () => ctx.params()['sku'],
loader: async ({ params: sku, abortSignal }) => {
const product = await productService.getBySku(sku, { signal: abortSignal });
if (!product) {
throw new RedirectCommand(router.parseUrl('/not-found'));
}
return product;
},
}),
};
},
The loader arguments include an abortSignal; pass it through to your fetch call. Whenever the router replaces a navigation or reverts one, it cancels the outstanding request via that signal, rather than allowing an abandoned request to complete and write into state that no longer matters.
Uncomfortable corners
All of the mechanics described so far are sound, but there are a handful of caveats to weigh before depending on them in production.
This is a developer preview API (22.2 as of writing). The shapes of ResourceContext, nonBlocking, and the internal snapshot machinery may shift between minor releases. Building a design system component library on top of route.resources means accepting that you will likely have to revisit it.
An error in a blocking resource takes down the whole navigation, not just its own component. Suppose a route carries three blocking resources in parallel and one throws: the router aborts the entire navigation and emits NavigationError, even though the other two resolved successfully and their data was perfectly usable. Partial activation does not exist — you get all blocking resources or none. Calling nonBlocking() avoids this trap, but only at the cost of accepting the full Resource<T> object as your input type rather than the unwrapped value.
Input binding works by name matching, with no type checking. createResourceOutletBindingEffects pairs entries in your resources map with your component's declared inputs via templateName. Rename an input, rename a resource key, or add an alias, and the binding fails silently. TypeScript will not flag it; you discover it at runtime when the input remains undefined.
Manual reload can fail without telling you. As illustrated earlier, .reload() returns false during transitions and rollback recovery rather than throwing or queueing. That is the correct behavior for consistency, but the practical effect is that a refresh button pressed at the wrong moment does nothing at all, and inspecting the returned boolean is the only way to know.
Params identity is a footgun the type system stays quiet about. Handing over ctx.params() rather than ctx.params()['id'] compiles cleanly and appears to work in the first manual test, then starts reloading on every navigation once more than one route parameter is actually changing shape. Bugs like this survive a demo and resurface three sprints later as "why does this refetch on every click."
Testing route resources properly means testing through the router. The freezing and rollback logic lives in subscriptions to router.events tied to NavigationStart / NavigationEnd / NavigationCancel, so a shallow component test that stubs your service exercises none of it. Only a RouterTestingHarness (or something equivalent) that actually drives navigations will reveal the freeze/unfreeze cycle; plain TestBed.createComponent is not enough.
Final thoughts
Route resources address a genuine problem: resolvers impose a sequential waterfall and cannot be refreshed without a full renavigation. What the documentation phrases as "the router masks loading states during pending navigations" is, mechanically speaking, a computed signal switching between a frozen snapshot and the live one, coordinated by five router events and a small state machine for rollback recovery. There is no magic involved — it is a resource wrapper that subscribes to navigation lifecycle events, freezes a value at the appropriate moment, and releases it at the appropriate moment.
If you adopt this today, keep it clear of anything load-bearing until it leaves developer preview, and pay attention to what reload() returns.
