Signal-based resolvers: parallel instead of waterfall, blocking or non-blocking, plus a look at Signal Stores
Resolvers are as old as the Angular Router itself. They load data before a route is activated and return an Observable or a Promise for it. In a world where data access increasingly goes through resource, rxResource, and httpResource, that feels out of place. With version 22.2, the router therefore gains an alternative in developer preview: Router Resources. They not only fit Signals better, they also bring a performance benefit, because the router loads them in parallel instead of one after another.
This article explains the new API, shows both flavors using our demo application Flights42, and then looks at how Router Resources work together with Signal Stores.
Update: This article has been revised for the final release of Angular 22.2. The first version was based on the prerelease 22.2.0-next.5, which behaved differently in some details.
📂 Source code (branch: 22.2)
What Are Router Resources?
With Router Resources, a route gets a resources property that returns ordinary resources. The router takes care of their lifecycle and passes the results on to the component.
The second reason for the new API is at least as important as the nicer interface: performance. The router processes classic resolvers sequentially along the route hierarchy. If the parent route needs 2 seconds for its data and the child route 3, the user waits 5 seconds for the navigation. Router Resources, by contrast, all start at the same time, even when they block. In the same example, the navigation now takes only 3 seconds, as long as the slowest resource:

Besides such blocking Router Resources, Router Resources can also be defined as non-blocking. This means the router doesn't wait at all. It activates the route immediately and hands the still-loading resource to the component, which then renders the loading state itself.
Setting Up Router Resources
The feature is enabled via withRouterResources when configuring the router:
// src/app/app.config.ts
import {
provideRouter,
withComponentInputBinding,
withRouterResources,
} from '@angular/router';
[...]
export const appConfig: ApplicationConfig = {
providers: [
[...]
provideRouter(routes, withComponentInputBinding(), withRouterResources()),
],
};
Router Resources ship in Angular 22.2 as a developer preview. The API is public and essentially complete, but details may still change before it is declared stable. In addition, withComponentInputBinding is used so that the router binds the results of the resources to the component as inputs.
Blocking Router Resources
With this preparation in place, the first Router Resource can be defined. The route for editing a passenger loads the record before the component appears:
// src/app/domains/ticketing/ticketing.routes.ts
{
path: 'passenger-edit/:id',
component: PassengerEdit,
resources: (ctx) => ({
passenger: createSimplePassengerResource(ctx.params),
}),
},
The resources function runs in an injection context and receives a ResourceContext. It provides params, queryParams, fragment, and data as Signals. The factory function derives the id from them and creates a perfectly ordinary httpResource:
// [...]/feature-booking/passenger-edit/simple-passenger-resource.ts
import { computed, inject, Signal } from '@angular/core';
import { Params } from '@angular/router';
import { PassengerClient } from '../../data/passenger-client';
export function createSimplePassengerResource(params: Signal<Params>) {
const passengerClient = inject(PassengerClient);
const id = computed(() => Number(params()['id'] ?? 0));
return passengerClient.findPassengerResourceById(id);
}
Without any further ado, such a resource is blocking. Just like with a resolver, the router waits until the value is available and only then activates the route. Thanks to withComponentInputBinding, it's not the resource itself that ends up in the component, but the already unwrapped value:
// src/app/domains/ticketing/feature-booking/passenger-edit/passenger-edit.ts
@Component({ [...] })
export class PassengerEdit {
[...]
protected readonly passenger = input<Passenger>(initPassenger);
}
This is the same input a resolver populated before. For the component, the switch is invisible, which makes migration pleasant. If the route parameter changes later while the component is reused, the resource fetches the new value on its own, because ctx.params is a Signal.
One detail deserves attention: the httpResource from the PassengerClient has a default value, an empty passenger. In the prereleases of 22.2, such a resource didn't block, because from the router's point of view a value was already present. Anyone who wanted to block had to do without the default value. The final 22.2 release instead looks solely at the loading state: the router waits until the resource's isLoading switches to false. A default value no longer stands in the way of blocking.
Non-Blocking Router Resources
Navigation shouldn't always wait for data. Often it's better to show the target page right away and make the loading state visible there. That's exactly what nonBlocking is for. The demo's luggage management uses it for its detail route:
// src/app/domains/luggage/feature-luggage/luggage.routes.ts
import { nonBlocking, Routes } from '@angular/router';
[...]
{
path: ':id',
component: LuggageDetail,
resources: (ctx) => ({
luggage: nonBlocking(createLuggageResource(ctx.params)),
}),
},
The factory function createLuggageResource is structured like the passenger example and delegates to the LuggageClient. Its loader is deliberately slow so the effect becomes visible, and it reports an unknown piece of luggage as an error:
// src/app/domains/luggage/data/luggage-client.ts
findLuggageById(id: Signal<number>) {
return resource({
params: id,
loader: async ({ params: id }) => {
// Deliberately delayed to make the loading state visible
await new Promise((resolve) => setTimeout(resolve, 2000));
const luggage = this.getLuggage().find((item) => item.id === id);
if (!luggage) {
throw new Error(`Luggage ${id} not found`);
}
return luggage;
},
defaultValue: initLuggage,
});
}
The router now activates the route immediately. In this case, the component doesn't receive the unwrapped value but the complete Resource as its input:
// src/app/domains/luggage/feature-luggage/luggage-detail/luggage-detail.ts
@Component({ [...] })
export class LuggageDetail {
readonly luggage = input.required<Resource<Luggage>>();
}
This makes all of the resource's state Signals available in the template. The template shows the loading state via isLoading. For the error case, it relies on another addition in Angular 22.2, the @boundary block:
<!-- [...]/luggage/feature-luggage/luggage-detail/luggage-detail.html -->
@boundary {
@let resource = luggage();
@let item = resource.value();
@if (resource.isLoading()) {
<p>Loading luggage …</p>
} @else {
<form> [...] </form>
}
} @error {
<p>Luggage could not be loaded.</p>
}
If the loader fails, value() throws the error as an exception so that it doesn't go unnoticed. Until now, a template therefore had to check the error() signal first and was only allowed to read value() in the success case. A @boundary block catches such errors for the enclosed part of the template and renders its @error block instead. Navigating to /luggage/9999 in the demo shows the message from the @error block after the delay. The link back to the overview is outside the boundary and therefore stays visible in any case. Since the resource has a default value, item is never undefined either, which saves the template one more check.
The division of labor is clear: blocking resources deliver finished values and guarantee that the target page is complete. Non-blocking resources deliver the loading lifecycle along with the data and leave rendering the intermediate state to the target page.
Modern Angular
✓ Already updated to Angular 22!
You'll find more on resources, Signals, and modern Angular architecture in my new eBook Modern Angular. It covers Signals, architecture, testing, AI assistants, and practical solutions for modern business applications.
Reload and Redirect
Two more capabilities set Router Resources apart from resolvers. The first concerns reloading. Anyone who wanted to fetch a resolver's data again had to re-activate the route, for instance with onSameUrlNavigation: 'reload'. That repeats the entire navigation, including guards and resolvers. A Router Resource, on the other hand, can be reloaded on its own, without the router starting a navigation.
With a blocking resource, however, the component only receives the value, not the resource itself. Access to it is provided by the ActivatedRoute, which offers all of the route's resources under their keys:
// src/app/domains/ticketing/feature-booking/passenger-edit/passenger-edit.ts
import { ActivatedRoute } from '@angular/router';
[...]
@Component({ [...] })
export class PassengerEdit {
[...]
private readonly passengerResource =
inject(ActivatedRoute).resources?.['passenger'];
protected reload(): void {
// Reloads only this resource without re-activating the route
this.passengerResource?.reload();
}
}
The ?. accounts for the fact that, for demonstration purposes, the application can also run the same route with a resolver. In that case, there is no resource. A button in the template calls reload. Since the router binds the value of a blocking resource to the input via an effect, the new record arrives in the component on its own once loaded. In the demo, this also resets the form, because passengerModel is a linkedSignal on the input: local changes are discarded, and the record comes fresh from the server.
The second capability concerns missing data. The httpResource from the blocking example reports an unknown passenger as an error, since the API responds with 404. With a blocking resource, the router then cancels the navigation and emits a NavigationError. The user stays on the previous page. Usually, a redirect is the better reaction, for example to a not-found page. For this, the loader throws a RedirectCommand. Since 22.2, the router officially supports throwing a RedirectCommand, in guards and resolvers as well. The router cancels the navigation and redirects to the given URL.
Since httpResource has no loader of its own, the demo falls back on resource for this. The new factory function calls the PassengerClient and translates the 404 error into a redirect:
// [...]/feature-booking/passenger-edit/passenger-resource.ts
import { HttpErrorResponse } from '@angular/common/http';
import { inject, resource, Signal } from '@angular/core';
import { Params, RedirectCommand, Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { PassengerClient } from '../../data/passenger-client';
export function createPassengerResource(params: Signal<Params>) {
const passengerClient = inject(PassengerClient);
const router = inject(Router);
return resource({
params: () => Number(params()['id'] ?? 0),
loader: async ({ params: id }) => {
try {
return await firstValueFrom(passengerClient.findById(String(id)));
} catch (error) {
if (error instanceof HttpErrorResponse && error.status === 404) {
// Cancels the navigation and redirects instead of failing it
throw new RedirectCommand(router.parseUrl('/not-found'));
}
throw error;
}
},
});
}
In the demo, the route passenger-edit/:id now uses this factory function instead of createSimplePassengerResource. The route not-found shows a plain notice page. Navigating to passenger-edit/999999 takes you there without the target page ever appearing. This only works with blocking resources, because only there is the router still waiting for the result. With non-blocking resources, the navigation has already completed, an error ends up in error(), and the component decides for itself how to react.
Router Resources and Signal Stores
In many applications, it's not the route that loads the data but a Signal Store. There are three ways to combine such a store with Router Resources.
The store hands out its resource. If a store defines its resource itself, for instance in withProps, and exposes it, the route can use it directly. The demo shows this with the PassengerStore. The factory function for the route passes the id Signal to the store and returns the store's resource:
// [...]/feature-booking/passenger-edit/shared-passenger-resource.ts
export function createSharedPassengerResource(params: Signal<Params>) {
const store = inject(PassengerStore);
const id = computed(() => Number(params()['id'] ?? 0));
store.load(id);
return store.passenger;
}
Store and router now share the same resource. The router waits for it and binds its value to the component, and anyone who needs the value or loading state outside the route reads it from the store.
Waiting for the store. If the store encapsulates its resource, a Router Resource of its own can wait for it: the loader sets the id on the store and waits until the store's loading state ends. The demo shows this in store-passenger-resource.ts. A helper function like waitFor bridges the gap between the Signal world and the Promise world of the loader: it observes the Signal with an effect and resolves a Promise as soon as the expected value arrives. This works, but I'm not particularly fond of it: the resource exists only so that the router has something to wait for, and it wraps a mechanism a second time that is already complete on its own.
A dedicated resource for the route. My favorite is the approach from the previous sections: an ordinary resource created specifically for the route that delegates data access to a service. This also matches the intention behind Router Resources. They often need knowledge about the concrete use case, for example when they redirect to another route if data is missing. That knowledge belongs to the route, not to a store that is used in several places. The store remains responsible for the state the component manages after the navigation.
After consulting with the Angular team, I can say that they consider this a good solution as well. The router is the source of truth for routing parameters. The component receives the value of the Router Resource as an input and can pass it on to the store.
Alternatively, the store could access the router itself and synchronize the current routing parameters with its state. Personally, I prefer going through the component, because in my view the store shouldn't know about UI details and therefore shouldn't know about the router either.
Learn More: Angular Architecture Workshop (Remote, Interactive, Advanced)
Become an expert in enterprise-wide and long-lived Angular applications with our Angular Architecture Workshop!

German Version | English Version
Summary
Router Resources bring the loading of route data into the Signal world. A route declares its data as ordinary resources, the router starts them all in parallel and binds the results to the component as inputs. The old resolver waterfall is gone. In the example, the waiting time shrank from 5 to 3 seconds. With Angular 22.2, the API is public as a developer preview, and a default value no longer prevents a resource from blocking.
Blocking resources behave like resolvers with a better interface, non-blocking ones deliver the loading lifecycle directly to the target page, where a @boundary block catches the error case. Via the ActivatedRoute, a resource can be reloaded without a new navigation, and a RedirectCommand in the loader redirects when data is missing. For the interplay with Signal Stores, one rule has proven itself for me: the route gets its own resource that delegates to a service, and the store takes care of the state after the navigation.
