Resolver auf Signal-Basis: parallel statt Wasserfall, blockierend oder nicht blockierend
Resolver sind so alt wie der Angular Router selbst. Sie laden Daten, bevor eine Route aktiviert wird, und liefern dafür ein Observable oder Promise. In einer Welt, in der Datenzugriff zunehmend über resource, rxResource und httpResource läuft, wirkt das wie ein Fremdkörper. Mit Version 22.2.0-next.5 bekommt der Router deshalb eine experimentelle Alternative: Router Resources. Sie passen nicht nur besser zu Signals, sondern bringen auch einen Performance-Vorteil mit, weil der Router sie parallel statt nacheinander lädt.
Dieser Artikel erklärt die neue API, zeigt beide Spielarten anhand unserer Demo-Anwendung Flights42 und geht anschließend der Frage nach, wie Router Resources mit Signal Stores zusammenspielen.
📂 Quellcode (Branch: 22.2.0-next.5)
Was sind Router Resources?
Mit Router Resources erhält eine Route eine resources-Eigenschaft, die gewöhnliche Resources liefert. Der Router kümmert sich um deren Lebenszyklus und reicht die Ergebnisse an die Komponente weiter.
Der zweite Grund für die neue API ist mindestens so wichtig wie die schönere Schnittstelle: Performance. Klassische Resolver arbeitet der Router entlang der Routen-Hierarchie sequenziell ab. Braucht die Eltern-Route 2 Sekunden für ihre Daten und die Kind-Route 3, wartet der Benutzer 5 Sekunden auf die Navigation. Router Resources starten dagegen alle gleichzeitig, und zwar auch dann, wenn sie blockieren. Im selben Beispiel dauert die Navigation nur noch 3 Sekunden, also so lange wie die langsamste Resource:

Neben solchen Blocking Router Resources lassen sich Router Resources auch als non-blocking definieren. Das bedeutet, dass der Router gar nicht erst wartet. Er aktiviert die Route sofort und reicht die noch ladende Resource an die Komponente weiter, die den Ladezustand dann selbst darstellt.
Router Resources einrichten
Aktiviert wird das Feature über withRouterResources beim Einrichten des Routers:
// src/app/app.config.ts
import {
ɵwithRouterResources as withRouterResources,
provideRouter,
withComponentInputBinding,
} from '@angular/router';
[...]
export const appConfig: ApplicationConfig = {
providers: [
[...]
provideRouter(
routes,
withComponentInputBinding(),
withRouterResources()
),
],
};
Router Resources sind noch experimentell. Deshalb beginnen die Importe mit dem Präfix ɵ: In 22.2.0-next.5 exportiert der Router die neuen Symbole nur privat, weshalb die Demo sie beim Import auf ihre offiziellen Namen umbenennt. Zusätzlich kommt withComponentInputBinding zum Einsatz, damit der Router die Ergebnisse der Resources als Inputs an die Komponente bindet.
Auch die resources-Eigenschaft ist noch nicht Teil des öffentlichen Route-Typs. Die Laufzeit wertet sie bereits aus, für den Compiler ergänzt die Demo sie über eine kleine Typdeklaration:
// src/app/domains/shared/util-common/resource-route.d.ts
import { Resource } from '@angular/core';
import {
ɵResourceContext as ResourceContext,
ɵResourceResult as ResourceResult,
} from '@angular/router';
declare module '@angular/router' {
interface Route {
resources?: (
ctx: ResourceContext,
) => ResourceResult | Promise<ResourceResult>;
}
interface ActivatedRoute {
resources?: Record<string, Resource<unknown> & { reload(): boolean }>;
}
}
Der zweite Teil der Deklaration, resources an der ActivatedRoute, kommt weiter unten beim Neuladen ins Spiel.
Blocking Router Resources
Nach diesen Vorbereitungen lässt sich die erste Router Resource definieren. Die Route zum Editieren eines Passagiers lädt den Datensatz, bevor die Komponente erscheint:
// src/app/domains/ticketing/ticketing.routes.ts
{
path: 'passenger-edit/:id',
component: PassengerEdit,
resources: (ctx) => ({
passenger: createSimplePassengerResource(ctx.params),
}),
},
Die resources-Funktion läuft in einem Injection Context und bekommt einen ResourceContext übergeben. Er stellt unter anderem params, queryParams und data als Signals bereit. Die Fabrikfunktion leitet daraus die Id ab und erzeugt eine ganz normale 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, {
withDefaultValue: false,
});
}
Ohne weiteres Zutun ist eine solche Resource blockierend. Der Router wartet wie bei einem Resolver, bis der Wert vorliegt, und aktiviert erst dann die Route. Dank withComponentInputBinding landet dabei nicht die Resource selbst in der Komponente, sondern der bereits ausgepackte Wert:
// src/app/domains/ticketing/feature-booking/passenger-edit/passenger-edit.ts
@Component({ [...] })
export class PassengerEdit {
[...]
protected readonly passenger = input.required<Passenger>();
}
Das ist derselbe Input, den zuvor ein Resolver befüllt hat. Für die Komponente ist die Umstellung also unsichtbar, was die Migration angenehm macht. Ändert sich später der Routen-Parameter, während die Komponente wiederverwendet wird, lädt die Resource den neuen Wert von selbst nach, denn ctx.params ist ein Signal.
Ein Detail verdient noch Aufmerksamkeit: Der Aufruf oben deaktiviert mit withDefaultValue: false den Default-Wert der httpResource. In 22.2.0-next.5 blockiert eine Resource mit Default-Wert nämlich nicht, weil aus Sicht des Routers bereits ein Wert vorliegt. Dieses Detail soll sich aber noch ändern. Bis dahin verzichtet man auf defaultValue, wenn die Resource blockieren soll.
Non-Blocking Router Resources
Nicht immer soll die Navigation auf Daten warten. Oft ist es besser, die Zielseite sofort anzuzeigen und den Ladezustand dort sichtbar zu machen. Genau dafür gibt es nonBlocking. Die Gepäck-Verwaltung der Demo nutzt es für ihre Detail-Route:
// src/app/domains/luggage/feature-luggage/luggage.routes.ts
import { ɵnonBlocking as nonBlocking, Routes } from '@angular/router';
[...]
{
path: ':id',
component: LuggageDetail,
resources: (ctx) => ({
luggage: nonBlocking(createLuggageResource(ctx.params)),
}),
},
Die Fabrikfunktion createLuggageResource ist analog zum Passagier-Beispiel aufgebaut und delegiert an den LuggageClient. Dessen Loader ist absichtlich langsam, damit der Effekt sichtbar wird:
// src/app/domains/luggage/data/luggage-client.ts
findLuggageById(id: Signal<number>) {
return resource({
params: id,
loader: async ({ params: id }) => {
// Absichtlich verzögert, um den Ladezustand sichtbar zu machen
await new Promise((resolve) => setTimeout(resolve, 2000));
return this.getLuggage().find((item) => item.id === id);
},
});
}
Der Router aktiviert die Route nun sofort. Die Komponente bekommt in diesem Fall nicht den ausgepackten Wert, sondern die komplette Resource als Input:
// src/app/domains/luggage/feature-luggage/luggage-detail/luggage-detail.ts
@Component({ [...] })
export class LuggageDetail {
readonly luggage = input.required<Resource<Luggage | undefined>>();
}
Damit stehen im Template alle Zustands-Signals der Resource zur Verfügung, etwa isLoading und error:
<!-- src/app/domains/luggage/feature-luggage/luggage-detail/luggage-detail.html -->
@let resource = luggage();
@let item = resource.value();
@if (resource.isLoading()) {
<p>Loading luggage …</p>
} @else if (resource.error()) {
<p>Luggage could not be loaded.</p>
} @else if (item) {
<form> [...] </form>
}
Die Arbeitsteilung ist damit klar: Blockierende Resources liefern fertige Werte und garantieren, dass die Zielseite vollständig ist. Nicht blockierende Resources liefern den Lade-Lebenszyklus gleich mit und überlassen der Zielseite die Darstellung des Zwischenzustands.
Modern Angular
✓ Bereits auf Angular 22 aktualisiert!
Mehr zu Resources, Signals und moderner Angular-Architektur findest du in meinem neuen eBook Modern Angular. Es behandelt Signals, Architektur, Testing, KI-Assistenten und praxistaugliche Lösungen für moderne Business-Anwendungen.
Reload und Redirect
Zwei weitere Fähigkeiten unterscheiden Router Resources von Resolvern. Die erste betrifft das Nachladen. Wer die Daten eines Resolvers erneut abrufen wollte, musste bisher die Route neu aktivieren, etwa mit onSameUrlNavigation: 'reload'. Das wiederholt die komplette Navigation samt Guards und Resolvern. Eine Router Resource lässt sich dagegen gezielt neu laden, ohne dass der Router eine Navigation anstößt.
Bei einer blockierenden Resource bekommt die Komponente allerdings nur den Wert, nicht die Resource selbst. Den Zugriff darauf liefert die ActivatedRoute, die alle Resources der Route unter ihrem Schlüssel anbietet:
// 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 {
// Lädt nur diese Resource neu, ohne die Route erneut zu aktivieren
this.passengerResource?.reload();
}
}
Das ?. trägt dem Umstand Rechnung, dass die Demo dieselbe Route wahlweise auch mit einem Resolver betreibt; dann gibt es keine Resource. Ein Button im Template ruft reload auf. Da der Router den Wert einer blockierenden Resource über einen Effect an den Input bindet, kommt der neue Datensatz nach dem Laden von selbst in der Komponente an. In der Demo setzt das zugleich das Formular zurück, weil passengerModel ein linkedSignal auf den Input ist: Lokale Änderungen werden verworfen, der Datensatz kommt frisch vom Server.
Die zweite Fähigkeit betrifft fehlende Daten. Die httpResource aus dem Blocking-Beispiel meldet einen unbekannten Passagier als Fehler, denn die API antwortet mit 404. Bei einer blockierenden Resource bricht der Router die Navigation daraufhin ab und meldet einen NavigationError; der Benutzer bleibt auf der bisherigen Seite. Meist ist eine Umleitung die bessere Reaktion, etwa auf eine Not-Found-Seite. Dafür wirft der Loader ein RedirectCommand. Der Router bricht die Navigation ab und leitet auf die angegebene URL um.
Da httpResource keinen eigenen Loader hat, greift die Demo dafür auf resource zurück. Die neue Fabrikfunktion ruft den PassengerClient auf und übersetzt den 404-Fehler in eine Umleitung:
// .../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) {
// Bricht die Navigation ab und leitet um, statt sie scheitern zu lassen
throw new RedirectCommand(router.parseUrl('/not-found'));
}
throw error;
}
},
});
}
Die Route passenger-edit/:id verwendet in der Demo nun diese Fabrikfunktion anstelle von createSimplePassengerResource; die Route not-found zeigt eine schlichte Hinweisseite. Ruft man passenger-edit/999999 auf, landet man dort, ohne dass die Zielseite je erscheint. Das funktioniert nur bei blockierenden Resources, denn nur dort wartet der Router noch auf das Ergebnis. Bei nicht blockierenden Resources ist die Navigation bereits abgeschlossen, ein Fehler landet in error(), und die Komponente entscheidet selbst, wie sie darauf reagiert.
Router Resources und Signal Stores
In vielen Anwendungen lädt nicht die Route die Daten, sondern ein Signal Store. In der Demo verwaltet der PassengerDetailStore den aktuellen Passagier. Er bekommt eine Id gesetzt und lädt den Datensatz daraufhin selbstständig über eine eingebettete Resource. Wie bringt man einen solchen Store mit einer blockierenden Router Resource zusammen?
Die Demo zeigt dafür folgende Lösung. Eine handgeschriebene Resource setzt im Loader die Id am Store, wartet, bis der Store fertig geladen hat, und liefert dann dessen Wert:
// .../feature-booking/passenger-edit/store-passenger-resource.ts
export function createStorePassengerResource(params: Signal<Params>) {
const store = inject(PassengerDetailStore);
const passengerLoaded = waitFor(store.passengerIsLoading, false);
return resource({
params: () => Number(params()['id'] ?? 0),
loader: async ({ params: id, abortSignal }) => {
store.setPassengerId(id);
await passengerLoaded(abortSignal);
return store.passengerValue();
},
});
}
Die Hilfsfunktion waitFor schlägt dabei die Brücke von der Signal-Welt in die Promise-Welt des Loaders. Sie beobachtet das übergebene Signal mit einem effect und löst ein Promise auf, sobald der erwartete Wert eintrifft. Auf ihren Abdruck verzichte ich hier, das verlinkte Original ist überschaubar.
Diese Lösung funktioniert, aber ganz zufrieden macht sie mich trotzdem nicht. Der Store lädt bereits reaktiv, sobald sich seine Id ändert. Die Resource darüber existiert nur, damit der Router etwas zum Abwarten hat. Das ist eine zweite Verpackung um einen Mechanismus, der für sich genommen schon vollständig ist.
Die Verpackung lässt sich vermeiden, wenn man den Spieß umdreht: Statt eine Resource um den Store zu bauen, gibt der Store seine Resource selbst heraus. Ein kleiner Signal Store genügt dafür. Er erzeugt die Resource in withProps und stellt sie in zwei Varianten bereit: intern die veränderbare, nach außen die schreibgeschützte:
// .../feature-booking/passenger-edit/passenger-store.ts
export const PassengerStore = signalStore(
{ providedIn: 'root' },
withState({
passengerId: 0,
}),
withProps((store) => {
const _passenger = inject(PassengerClient).findPassengerResourceById(
store.passengerId,
{ withDefaultValue: false },
);
return {
_passenger,
passenger: _passenger.asReadonly(),
};
}),
withMethods((store) => ({
// Akzeptiert einen Wert oder ein Signal
load: signalMethod<number>((id) => patchState(store, { passengerId: id })),
})),
);
Der Unterstrich in _passenger ist mehr als eine Konvention: NgRx Signals behandelt so benannte Mitglieder als privat und nimmt sie aus dem öffentlichen Typ des Stores heraus. Von außen sichtbar ist nur passenger, die über asReadonly schreibgeschützte Sicht auf dieselbe Resource. Verändern lässt sie sich ausschließlich über load. Die Methode basiert auf signalMethod und nimmt wahlweise einen Wert oder ein Signal entgegen; im zweiten Fall hält sie die Id reaktiv aktuell. Die Fabrikfunktion für die Route reicht das Id-Signal an load weiter und gibt die Resource des Stores zurück:
// .../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 und Router teilen sich nun dieselbe Resource. Der Router wartet auf sie und bindet ihren Wert an die Komponente, Parameterwechsel bei wiederverwendeter Komponente kommen über das Signal weiterhin im Store an, und wer Wert oder Ladezustand außerhalb der Route braucht, liest sie direkt am Store. Da die Resource ohne Default-Wert auskommt, blockiert sie wie gewünscht.
Bewertung des Zusammenspiels von Router Resources und Stores und Alternative
Diese Überlegungen zeigen vor allem eines: Muster für das Zusammenspiel von Router Resources und Stores müssen sich erst noch etablieren. Jede der gezeigten Varianten hat ihren Preis. Die Brücke über waitFor verpackt den Store ein zweites Mal, der geteilte Store muss eigens dafür gebaut sein und seine Resource herausgeben, und der Guard verzichtet auf das Blockieren. Möglicherweise müssen Angular und NgRx hier auch noch einen Schritt aufeinander zugehen.
Wer ohnehin nicht blockieren will, braucht weder das eine noch das andere. Dann genügt ein Guard, der den Store synchron anstößt und sofort true liefert:
export const passengerGuard: CanActivateFn = (route) => {
const store = inject(PassengerDetailStore);
store.setPassengerId(Number(route.paramMap.get('id') ?? 0));
return true;
};
Die Navigation läuft ohne Verzögerung durch, der Store lädt im Hintergrund, und die Komponente bezieht Wert und Ladezustand direkt aus dem Store. Das entspricht dem nicht blockierenden Verhalten von oben, kommt aber ganz ohne zusätzliche Resource aus. Die beiden blockierenden Varianten bleiben damit den Fällen vorbehalten, in denen der Router tatsächlich auf Store-Daten warten soll.
Mehr dazu: Angular Architecture Workshop (Remote, Interaktiv, Advanced)
Werde zum Experten für unternehmensweite und langlebige Angular-Anwendungen mit unserem Angular Architecture Workshop!

Deutsche Version | English Version
Zusammenfassung
Router Resources holen das Laden von Routen-Daten in die Signal-Welt. Eine Route deklariert ihre Daten als gewöhnliche Resources, der Router startet sie alle parallel und bindet die Ergebnisse als Inputs an die Komponente. Der alte Resolver-Wasserfall entfällt, im Beispiel schrumpfte die Wartezeit von 5 auf 3 Sekunden.
Blockierende Resources verhalten sich dabei wie Resolver mit besserer Schnittstelle, nicht blockierende liefern den Lade-Lebenszyklus direkt an die Zielseite. Über die ActivatedRoute lässt sich eine Resource ohne neue Navigation nachladen, und ein RedirectCommand im Loader leitet um, wenn Daten fehlen. Beim Zusammenspiel mit Signal Stores lohnt sich ein zweiter Blick: Wer blockieren muss, lässt den Store seine Resource selbst herausgeben oder überbrückt ihn mit einer eigenen Resource; wer das nicht muss, stößt ihn einfach über einen Guard an. Noch ist das alles experimentell, die Richtung stimmt aber.
