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 inapp.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
- 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. - 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.
- Ein Ort für Seiteneffekt-Orchestrierung (Effects), unabhängig von Komponenten-Lebenszyklen.
- Nachvollziehbarkeit im Team - ein neuer Entwickler liest die
actions.tsund 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
- Pattern-Katalog - Eintrag "Signals vs. NgRx".
- Tech-Stack-Katalog - NgRx "(punktuell)".
app.config.ts- der Kommentar amprovideStore-Block.- NgRx-Doku: https://ngrx.io/guide/store · Angular Signals: https://angular.dev/guide/signals