Router Resources: Daten laden mit dem Angular Router

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 Angular 22.2 bekommt der Router deshalb eine Alternative als Developer Preview: 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 darauf ein, wie Router Resources mit Signal Stores zusammenspielen.

Update: Dieser Artikel wurde für die finale Version von Angular 22.2 überarbeitet. Die erste Fassung bezog sich auf die Vorabversion 22.2.0-next.5, die sich in einigen Details anders verhielt.

📂 Quellcode (Branch: 22.2)

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:

Zeitdiagramm: Zwei Resolver mit 2 und 3 Sekunden laufen nacheinander und blockieren die Navigation 5 Sekunden. Als Router Resources laufen beide parallel und die Navigation ist nach 3 Sekunden abgeschlossen.

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 {
  provideRouter,
  withComponentInputBinding,
  withRouterResources,
} from '@angular/router';

[...]

export const appConfig: ApplicationConfig = {
  providers: [
    [...]
    provideRouter(routes, withComponentInputBinding(), withRouterResources()),
  ],
};

Router Resources sind in Angular 22.2 als Developer Preview enthalten. Die API ist damit öffentlich und im Wesentlichen fertig, kann sich vor der Stabilisierung aber noch in Details ändern. Zusätzlich kommt withComponentInputBinding zum Einsatz, damit der Router die Ergebnisse der Resources als Inputs an die Komponente bindet.

Blocking Router Resources

Nach dieser Vorbereitung 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 params, queryParams, fragment 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);
}

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<Passenger>(initPassenger);
}

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: Die httpResource aus dem PassengerClient hat einen Default-Wert, nämlich einen leeren Passagier. In den Vorabversionen von 22.2 blockierte eine solche Resource nicht, weil aus Sicht des Routers bereits ein Wert vorlag. Wer blockieren wollte, musste auf den Default-Wert verzichten. Die finale Version 22.2 richtet sich stattdessen ausschließlich nach dem Ladezustand: Der Router wartet, bis isLoading der Resource auf false wechselt. Ein Default-Wert steht dem Blockieren damit nicht mehr im Weg.

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, 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, und meldet ein unbekanntes Gepäckstück als Fehler:

// 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));
      const luggage = this.getLuggage().find((item) => item.id === id);
      if (!luggage) {
        throw new Error(`Luggage ${id} not found`);
      }
      return luggage;
    },
    defaultValue: initLuggage,
  });
}

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>>();
}

Damit stehen im Template alle Zustands-Signals der Resource zur Verfügung. Den Ladezustand zeigt das Template über isLoading an. Für den Fehlerfall greift es auf eine weitere Neuerung von Angular 22.2 zurück, den @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>
}

Schlägt der Loader fehl, wirft value() den Fehler als Exception, damit er nicht unbemerkt bleibt. Bisher musste das Template deshalb zuerst das Signal error() prüfen und durfte value() nur im Erfolgsfall lesen. Ein @boundary-Block fängt solche Fehler für den umschlossenen Teil des Templates ab und zeigt stattdessen seinen @error-Block an. Ruft man in der Demo /luggage/9999 auf, erscheint nach der Verzögerung der Hinweis aus dem @error-Block. Der Link zurück zur Übersicht liegt außerhalb der Boundary und bleibt daher in jedem Fall sichtbar. Da die Resource einen Default-Wert hat, ist item außerdem nie undefined, weshalb sich das Template eine weitere Prüfung spart.

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.

NOTE

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.

Modern Angular - Signal-first, Architecture-first, Practice-first

Mehr zum Buch →

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 Anwendung dieselbe Route zu Demonstrationszwecken 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. Seit 22.2 unterstützt der Router das Werfen eines RedirectCommand offiziell, und zwar auch in Guards und Resolvern. 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. Für das Zusammenspiel mit Router Resources bieten sich drei Wege an.

Der Store gibt seine Resource heraus. Definiert ein Store seine Resource selbst, etwa in withProps, und veröffentlicht sie, kann die Route sie direkt verwenden. Die Demo zeigt das mit dem PassengerStore. Die Fabrikfunktion für die Route reicht das Id-Signal an den Store weiter und liefert dessen Resource 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 damit dieselbe Resource. Der Router wartet auf sie und bindet ihren Wert an die Komponente, und wer Wert oder Ladezustand außerhalb der Route braucht, liest sie am Store.

Auf den Store warten. Kapselt der Store seine Resource, lässt sich in einer eigenen Router Resource auf ihn warten: Der Loader setzt die Id am Store und wartet, bis dessen Ladezustand endet. Die Demo zeigt das in store-passenger-resource.ts. Die Brücke von der Signal-Welt in die Promise-Welt des Loaders schlägt dabei eine Hilfsfunktion wie waitFor, die das Signal mit einem effect beobachtet und ein Promise auflöst, sobald der erwartete Wert eintrifft. Das funktioniert, gefällt mir aber nicht sonderlich gut: Die Resource existiert nur, damit der Router etwas zum Abwarten hat, und verpackt einen Mechanismus ein zweites Mal, der für sich genommen schon vollständig ist.

Eine eigene Resource für die Route. Am besten gefällt mir die Variante aus den vorherigen Abschnitten: eine gewöhnliche Resource, die eigens für die Route angelegt wird und den Datenzugriff an einen Service delegiert. Das entspricht auch der Intention hinter Router Resources. Sie brauchen häufig Wissen über den konkreten Use Case, etwa wenn sie bei fehlenden Daten auf eine andere Route umleiten. Dieses Wissen gehört zur Route und nicht in einen Store, der an mehreren Stellen zum Einsatz kommt. Der Store bleibt für den Zustand zuständig, den die Komponente nach der Navigation verwaltet.

Nach Rücksprache mit dem Angular-Team ist das auch aus dessen Sicht eine gute Lösung. Der Router ist die Source of Truth für Routing-Parameter. Die Komponente bekommt den Wert der Router Resource als Input und kann ihn an den Store weitergeben.

Alternativ dazu könnte der Store auch selbst auf den Router zugreifen und die aktuellen Routing-Parameter mit seinem State synchronisieren. Ich persönlich bevorzuge den Weg über die Komponente, weil der Store aus meiner Sicht nichts über Details der UI und damit auch nichts über den Router wissen sollte.

Mehr dazu: Angular Architecture Workshop (Remote, Interaktiv, Advanced)

Werde zum Experten für unternehmensweite und langlebige Angular-Anwendungen mit unserem Angular Architecture Workshop!

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. Mit Angular 22.2 ist die API als Developer Preview öffentlich, und ein Default-Wert hindert eine Resource nicht mehr am Blockieren.

Blockierende Resources verhalten sich dabei wie Resolver mit besserer Schnittstelle, nicht blockierende liefern den Lade-Lebenszyklus direkt an die Zielseite, wo ein @boundary-Block den Fehlerfall abfängt. Ü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 hat sich für mich eine Regel bewährt: Die Route bekommt ihre eigene Resource, die an einen Service delegiert, und der Store kümmert sich um den Zustand nach der Navigation.

Modern Angular

Architecture · Concepts · Implementation

Ein praktischer Leitfaden für skalierbare Angular-Anwendungen mit modernen Architekturmustern und Signals.

Mehr zum Buch