Signals vs. NgRx - der Kontrast im selben Projekt

Zielgruppe: Entwickler:in, die verstehen will, wann sich das schwergewichtige NgRx gegenüber einem einfachen Signal-Service lohnt. Dieses Projekt baut denselben fachlichen Anwendungsfall ("Bücher-Liste laden, Buch anlegen, Exemplar hinzufügen") absichtlich zweimal: einmal mit Signals (catalog), einmal mit NgRx (catalog-admin). Das ist kein Zufall, sondern ein Lehr-Aufbau - siehe der Kommentar in app.config.ts.

Die zwei Implementierungen

Signal-Variante NgRx-Variante
Feature features/catalog/ features/catalog-admin/
Zustandsträger catalog.service.ts (1 Datei) catalog-admin/store/ (5 Dateien)
Registrierung keine (Service ist providedIn: 'root') provideStore, provideEffects, provideStoreDevtools in app.config.ts
Zeilen Code (ca.) ~60 ~140 (über 5 Dateien)
Abhängigkeiten nur @angular/core @ngrx/store, @ngrx/effects, @ngrx/store-devtools

Beide machen fachlich fast dasselbe. Der Unterschied ist reine Architektur.


Dieselbe Operation, beide Wege: "Bücher laden"

Signals (catalog.service.ts)

@Injectable({ providedIn: 'root' })
export class CatalogService {
  private readonly http = inject(HttpClient);

  readonly books = signal<BookResponse[]>([]);
  readonly loading = signal(false);
  readonly error = signal<string | null>(null);

  search(criteria: { title?: string; author?: string; onlyAvailable?: boolean }): void {
    this.loading.set(true);
    this.error.set(null);
    // ... HttpParams bauen ...
    this.http.get<PageResponse<BookResponse>>(`${API_BASE_URL}/api/books`, { params }).subscribe({
      next: (page) => { this.books.set(page.content); this.loading.set(false); },
      error: (err) => { this.error.set(this.extractErrorMessage(err)); this.loading.set(false); },
    });
  }
}

Ablauf: Komponente ruft service.search(...) → Service setzt Signals direkt → Template re-rendert. Ein Schritt, eine Datei, ein Aufruf.

NgRx (catalog-admin/store/)

Verteilt auf fünf Dateien:

1. catalog-admin.state.ts - die Zustandsform:

export interface CatalogAdminState {
  books: BookResponse[];
  loading: boolean;
  error: string | null;
}
export const initialCatalogAdminState: CatalogAdminState = { books: [], loading: false, error: null };

2. catalog-admin.actions.ts - jedes Ereignis wird benannt:

export const CatalogAdminActions = createActionGroup({
  source: 'Catalog Admin',
  events: {
    'Load Books': props<{ title?: string }>(),
    'Load Books Success': props<{ books: BookResponse[] }>(),
    'Load Books Failure': props<{ error: string }>(),
    // ... + Register Book (Success/Failure), Add Copy (Success/Failure)
  },
});

3. catalog-admin.reducer.ts - reine Zustandsübergänge, kein HTTP:

export const catalogAdminReducer = createReducer(
  initialCatalogAdminState,
  on(CatalogAdminActions.loadBooks,        (state) => ({ ...state, loading: true, error: null })),
  on(CatalogAdminActions.loadBooksSuccess, (state, { books }) => ({ ...state, books, loading: false })),
  on(CatalogAdminActions.loadBooksFailure, (state, { error }) => ({ ...state, error, loading: false })),
  // ...
);

4. catalog-admin.effects.ts - hier lebt der Seiteneffekt (HTTP):

loadBooks$ = createEffect(() =>
  this.actions$.pipe(
    ofType(CatalogAdminActions.loadBooks),
    mergeMap(({ title }) =>
      this.http.get<PageResponse<BookResponse>>(`${API_BASE_URL}/api/books`, { params }).pipe(
        map((page) => CatalogAdminActions.loadBooksSuccess({ books: page.content })),
        catchError((err) => of(CatalogAdminActions.loadBooksFailure({ error: this.extractErrorMessage(err) }))),
      ),
    ),
  ),
);

5. catalog-admin.selectors.ts - Lesezugriff:

export const selectCatalogAdminState = createFeatureSelector<CatalogAdminState>('catalogAdmin');
export const selectCatalogAdminBooks = createSelector(selectCatalogAdminState, (s) => s.books);
export const selectCatalogAdminLoading = createSelector(selectCatalogAdminState, (s) => s.loading);
export const selectCatalogAdminError = createSelector(selectCatalogAdminState, (s) => s.error);

Ablauf: Komponente dispatcht loadBooks({}) → Reducer setzt loading: true → Effect fängt die Action, macht HTTP, dispatcht loadBooksSuccess({ books }) → Reducer schreibt books + loading: false → Selector liefert den neuen Wert → Template re-rendert. Fünf Bausteine, ein klarer Weg durch jeden.


Die Komponenten im Vergleich

Signal-Komponente (catalog-search.component.ts)

export class CatalogSearchComponent {
  protected readonly catalogService = inject(CatalogService);
  // ... Form ...
  constructor() { this.search(); }
  protected search(): void { this.catalogService.search(this.searchForm.getRawValue()); }
}

Template liest catalogService.books(), catalogService.loading() direkt.

NgRx-Komponente (catalog-admin.component.ts)

export class CatalogAdminComponent {
  private readonly store = inject(Store);

  protected readonly books   = this.store.selectSignal(selectCatalogAdminBooks);
  protected readonly loading = this.store.selectSignal(selectCatalogAdminLoading);
  protected readonly error   = this.store.selectSignal(selectCatalogAdminError);

  constructor() { this.store.dispatch(CatalogAdminActions.loadBooks({})); }
  protected search(): void {
    const { title } = this.searchForm.getRawValue();
    this.store.dispatch(CatalogAdminActions.loadBooks({ title: title || undefined }));
  }
}

Wichtige Beobachtung: store.selectSignal(...) gibt einen Signal zurück. Das Template sieht in beiden Features identisch aus (books(), loading()). Signals sind nicht das "Gegenteil" von NgRx - sie sind die gemeinsame Lese-Schnittstelle. Der Unterschied liegt komplett im Schreib-Pfad und in der Zustands-Organisation.


Wo NgRx sichtbar mehr kann: Seiteneffekt-Verkettung

In catalog-admin.effects.ts:

reloadAfterRegister$ = createEffect(() =>
  this.actions$.pipe(
    ofType(CatalogAdminActions.registerBookSuccess),
    map(() => CatalogAdminActions.loadBooks({})),
  ),
);

"Wenn ein Buch erfolgreich angelegt wurde, lade die Liste neu." Das steht als deklarative Regel an einem Ort, entkoppelt von der Komponente.

In der Signal-Variante macht das die Komponente per Callback (loans.service.ts):

borrow(request: BorrowBookRequest, onSuccess: () => void): void {
  this.http.post(...).subscribe({ next: onSuccess, error: (err) => this.error.set(...) });
}
// in der Komponente:
protected borrow(): void {
  this.loansService.borrow(this.borrowForm.getRawValue(), () => { this.loadLoans(); this.borrowForm.reset(); });
}

Funktioniert - aber die "danach passiert X"-Logik sitzt in der Komponente, nicht an einem zentralen, testbaren Ort. Bei einer Handvoll Übergängen egal. Bei einem Workflow mit zehn voneinander abhängigen Schritten wird der Effect-Ansatz überlegen.


Wo NgRx sichtbar mehr kostet

Reibung Signal-Variante NgRx-Variante
Neues Zustandsfeld hinzufügen 1 Zeile im Service State-Interface + ggf. Action + Reducer-on + Selector
Neue Operation 1 Methode Action(en) + Reducer-on(s) + Effect + ggf. Selector
Datei-Sprünge beim Lesen des Flows 1 Datei 4-5 Dateien
Einarbeitung signal/set/update Actions, Reducer, Effects, Selectors, createFeatureSelector, RxJS-Operatoren (mergeMap vs. switchMap vs. concatMap vs. exhaustMap)
Bundle 0 kB extra @ngrx/*

Wo NgRx sichtbar mehr gibt

  1. Redux DevTools - provideStoreDevtools({ maxAge: 25, ... }). Jede Aktion mit Zeitstempel, Payload, Zustand davor/danach. Time-Travel: klick zurück auf eine frühere Aktion, die App springt in den damaligen Zustand. Bei den Signal-Services gibt es nichts Vergleichbares - du siehst im Angular-DevTools-Properties-Tab nur den aktuellen Wert.
  2. Erzwungene Trennung "was passiert ist" (Action) von "wie der Zustand reagiert" (Reducer). Der Reducer ist eine reine Funktion - trivial zu testen, kein Mock nötig.
  3. Ein Ort für Seiteneffekt-Orchestrierung (Effects), unabhängig von Komponenten-Lebenszyklen.
  4. Nachvollziehbarkeit im Team - ein neuer Entwickler liest die actions.ts und weiß, was in diesem Feature alles passieren kann.

Die Faustregel dieses Projekts

Default: Signals. Für "lade Daten, zeige Spinner, zeige Fehler, gelegentlich eine Schreibaktion" ist das Signal-Trio die kleinstmögliche Lösung.

NgRx, wenn mehrere Komponenten denselben nicht-trivialen Zustand teilen, viele Zustandsübergänge voneinander abhängen, Seiteneffekte verkettet werden müssen, oder das Zeitreise-Debugging den Einarbeitungspreis wert ist.

catalog-admin ist im Projekt der einzige NgRx-Fall - und ehrlicherweise wäre auch dort Signals ausreichend gewesen. Es ist bewusst als Lern-Kontrast gebaut, nicht weil die Fachlichkeit es erzwingt. Die Erkenntnis daraus: NgRx ist ein Werkzeug für eine Zustandskomplexität, die die meisten CRUD-Features nie erreichen.

Übung

Portiere features/fines/ (klein, nur laden + pay) von Signals nach NgRx. Danach hast du beide Varianten desselben Features selbst gebaut und weißt aus erster Hand, wie sich die ~15 Zeilen fines.service.ts auf fünf Dateien verteilen - und ob dir der Redux-DevTools-Blick auf den Bezahl-Flow das wert ist. Siehe Frontend-Lernpfad, Vertiefungsaufgabe 3.

Weiterführend

⌂ Cockpit