NgRx SignalStore (das Paket @ngrx/signals) ist ein deklarativer Weg, Stores auf Basis von Signals zu bauen. Du setzt Features zusammen (withState, withComputed, withMethods, withHooks, withEntities…), und das Ergebnis ist eine injizierbare Klasse. Ihr Zustand wird als Signals bereitgestellt und lässt sich nur über die Methoden ändern, die du definierst.
Es ist der nächste Schritt nach dem selbstgebauten Store auf der Seite State Management: dieselben Ideen, aber mit einer Standardstruktur, fertigen Entities, RxJS-Integration für asynchrone Abläufe und wiederverwendbaren Bausteinen über mehrere Stores hinweg. Hier wird @ngrx/signals 22 verwendet, kompatibel mit Angular 22.
Aufbau eines Stores
Ein signalStore(...) ist eine Liste von Features, die der Reihe nach angewendet werden. Jedes Feature sieht, was die vorherigen hinzugefügt haben. withState definiert den Zustand: Jede Property wird zu einem Signal (store.todos()), und verschachtelte Objekte werden zu Deep Signals. withComputed fügt abgeleiteten Zustand hinzu, withMethods die Operationen. Nur Methoden dürfen patchState aufrufen. Der Zustand ist standardmäßig geschützt, du kannst ihn also nicht direkt von außerhalb des Stores ändern.
import { computed } from '@angular/core';
import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals';
type Filter = 'all' | 'open' | 'done';
interface Todo { id: number; title: string; done: boolean }
export const TodoStore = signalStore(
{ providedIn: 'root' }, // ohne die Option: dort in `providers` eintragen, wo du ihn brauchst
withState({ todos: [] as Todo[], filter: 'all' as Filter }),
withComputed(({ todos, filter }) => ({
visible: computed(() =>
filter() === 'all' ? todos() : todos().filter((t) => t.done === (filter() === 'done')),
),
openCount: computed(() => todos().filter((t) => !t.done).length),
})),
withMethods((store) => ({
add(title: string): void {
patchState(store, (s) => ({ todos: [...s.todos, { id: Date.now(), title, done: false }] }));
},
toggle(id: number): void {
patchState(store, (s) => ({ todos: s.todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) }));
},
setFilter(filter: Filter): void {
patchState(store, { filter });
},
})),
);patchState erhält entweder ein partielles Objekt ({ filter }) oder eine Funktion (state) => Partial<State>. Es akzeptiert außerdem beliebig viele Updater, die gemeinsam angewendet werden. Der Zustand ist immutable: Du gibst immer neue Objekte und Arrays zurück, sonst bemerken die Signals die Änderung nicht (dieselbe Regel wie bei signal()).
Wo der Store lebt
Wie bei jedem Service wird die Lebensdauer eines Stores durch den Injector bestimmt, in dem du ihn bereitstellst. Mit { providedIn: 'root' } bekommst du eine einzige globale Instanz, gut für die Benutzersitzung oder einen Warenkorb. Ohne die Option trägst du ihn in die providers einer Komponente oder einer Route ein und bekommst eine neue Instanz, die zusammen mit ihr zerstört wird. Das ist die richtige Wahl für den Zustand einer Seite.
// 1. Global: eine einzige Instanz für die ganze Anwendung
export const SessionStore = signalStore({ providedIn: 'root' }, withState({ user: null as User | null }));
// 2. Pro Seite / pro Komponente: neue Instanz, wird zusammen mit der Komponente zerstört
export const ProductsStore = signalStore(withEntities<Product>() /* ... */);
@Component({
providers: [ProductsStore], // jedes <app-catalog> hat seinen eigenen Store
template: `...`,
})
export class Catalog {
readonly store = inject(ProductsStore);
}
// 3. Pro Route (Feature): lebt so lange wie die Route
{ path: 'catalog', providers: [ProductsStore], loadComponent: () => import('./catalog') }Entities
withEntities<Product>() fügt eine normalisierte Collection hinzu: Im Zustand hast du ids und entityMap, und als Signal hast du entities(), ein abgeleitetes Array. Die Updater aus @ngrx/signals/entities (setAllEntities, addEntity, updateEntity, upsertEntity, removeEntity…) werden direkt an patchState übergeben. Für mehrere Collections im selben Store gibt es benannte Collections (withEntities({ entity: type<Product>(), collection: 'product' }) → productEntities()).
Asynchrone Abläufe mit rxMethod
rxMethod (aus @ngrx/signals/rxjs-interop) macht aus einer RxJS-Pipe eine Methode. Du kannst sie mit einem Wert, mit einem Observable oder mit einem Signal aufrufen. Bei einem Signal läuft die Methode bei jeder Änderung davon. Der Aufruf store.search(store.query) in onInit koppelt die Suche nur einmal an die Query. Der Rest ist gewöhnliches RxJS: debounceTime, switchMap, um den veralteten Request abzubrechen, und catchError innerhalb des switchMap, damit ein Fehler den Stream nicht beendet (siehe Fortgeschrittenes RxJS). Die Subscription wird von selbst geschlossen, wenn der Store zerstört wird.
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { setAllEntities, updateEntity, withEntities } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { catchError, debounceTime, distinctUntilChanged, EMPTY, pipe, switchMap, tap } from 'rxjs';
export const ProductsStore = signalStore(
withEntities<Product>(), // Zustand: ids + entityMap; Signal: entities()
withState({ query: '' }),
withRequestStatus(), // eigenes Feature (unten)
withMethods((store, http = inject(HttpClient)) => ({
setQuery: (query: string) => patchState(store, { query }),
buy: (id: number) =>
patchState(store, updateEntity({ id, changes: (p) => ({ stock: p.stock - 1 }) })),
search: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => patchState(store, setPending())),
switchMap((q) =>
http.get<Page<Product>>(`/api/products?q=${encodeURIComponent(q)}`).pipe(
tap((page) => patchState(store, setAllEntities(page.items), setFulfilled())),
catchError(() => {
patchState(store, setError('Der Server antwortet nicht.'));
return EMPTY; // der äußere Stream bleibt am Leben
}),
),
),
),
),
})),
withHooks({
onInit(store) {
store.search(store.query); // rxMethod erhält ein SIGNAL: Sie läuft bei jeder Änderung
},
}),
);tapResponse statt tap + catchError. Es tut dasselbe, nur kompakter, kommt aber aus dem separaten Paket @ngrx/operators. Das Beispiel hier verwendet nur RxJS.Wiederverwendbare Features
signalStoreFeature(...) fasst mehrere Features zu einer Funktion zusammen, die du jedem Store hinzufügen kannst, zum Beispiel withRequestStatus(): der Request-Zustand plus isPending und error. Die Updater sind einfache Funktionen, die ein Stück Zustand zurückgeben, daher kombinierst du sie mit anderen im selben patchState. So entfällt der kopierte Loading-/Error-Code in jedem Store.
import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withState } from '@ngrx/signals';
export type RequestStatus = 'idle' | 'pending' | 'fulfilled' | { error: string };
// Ein wiederverwendbarer Store-Baustein: Zustand + Computeds, in jeden signalStore einsetzbar
export function withRequestStatus() {
return signalStoreFeature(
withState<{ requestStatus: RequestStatus }>({ requestStatus: 'idle' }),
withComputed(({ requestStatus }) => ({
isPending: computed(() => requestStatus() === 'pending'),
error: computed(() => {
const s = requestStatus();
return typeof s === 'object' ? s.error : null;
}),
})),
);
}
// Updater: einfache Funktionen, die ein Stück Zustand zurückgeben → patchState(store, setPending())
export const setPending = () => ({ requestStatus: 'pending' as RequestStatus });
export const setFulfilled = () => ({ requestStatus: 'fulfilled' as RequestStatus });
export const setError = (error: string) => ({ requestStatus: { error } as RequestStatus });- Keine Produkte.
{
"query": "",
"inStockOnly": false,
"cart": 0,
"requestStatus": "idle",
"ids": []
}Der Rest des Werkzeugkastens
signalState ist die Variante „ohne Store“: ein Objekt aus Signals für den lokalen Zustand einer Komponente, ebenfalls mit patchState. withProps fügt Properties hinzu, die kein Zustand sind (Services, Resources, Observables). withLinkedState leistet für einen Store, was linkedSignal für eine Komponente leistet. getState liefert dir einen Snapshot des Zustands, und watchState benachrichtigt dich synchron bei jeder Änderung. Für Tests kannst du mit unprotected(store) aus @ngrx/signals/testing den Store in einen bestimmten Zustand versetzen. Das Paket hat außerdem ein Events-Plugin (@ngrx/signals/events) für den Redux-Stil mit Events und Reducern.
import { patchState, signalState } from '@ngrx/signals';
// Für den lokalen Zustand EINER Komponente: kein Store, nur ein Objekt aus Signals
@Component({ /* ... */ })
export class Filters {
readonly state = signalState({ query: '', page: 1, sort: 'name' as 'name' | 'price' });
next(): void {
patchState(this.state, (s) => ({ page: s.page + 1 }));
}
// im Template: state.query(), state.page()
}Wann ja, wann nein
- Feature-Zustand, der von mehreren Komponenten genutzt wird, mit asynchronen Operationen (Suche, Speichern, Paginierung).
- Entity-Collections, die oft aktualisiert werden (editierbare Listen, Warenkörbe, Tabellen).
- Mehrere Teams, die für alle Stores dieselbe Form und wiederverwendbare Features wollen.
- Du hast den klassischen NgRx Store und willst für neue Features etwas Leichteres. Beide können nebeneinander existieren.
- Der Zustand einer einzelnen Komponente:
signal()odersignalState. - Daten, die vom Server gelesen und nur angezeigt werden:
httpResource(siehe resource). - Ein kleiner Service mit zwei Signals und drei Methoden: Ein selbstgebauter Store ist für alle klarer.
- Anwendungen, in denen niemand im Team NgRx kennt und die Deadline morgen ist. Die Abhängigkeit hat Lernaufwand.
| Selbstgebauter Store | SignalStore | NgRx Store (Redux) | |
|---|---|---|---|
| Boilerplate | minimal | gering | hoch (Actions, Reducers, Effects, Selectors) |
| Standardstruktur | was das Team entscheidet | ja | ja, sehr strikt |
| Entities | selbst schreiben | withEntities | @ngrx/entity |
| Async | resource / RxJS | rxMethod, resource | Effects |
| Gut für | kleine Features | die meisten Anwendungen | komplexe Abläufe, Event-Auditing |
Häufige Stolperfallen
- Den Zustand mutieren (
s.todos.push(x)in einem Updater). Das Signal erhält dieselbe Referenz und nichts wird aktualisiert. Gib immer neue Arrays und Objekte zurück. providedIn: 'root'für den Zustand einer Seite. Der Zustand „klebt“ von einem Besuch zum nächsten und vermischt sich zwischen Komponenteninstanzen. Trage den Store in dieprovidersder Seite ein.catchErrornach demswitchMapin einerrxMethod. Der erste Fehler beendet den Stream und die Suche funktioniert nicht mehr. Setze es in den inneren Stream.- Business-Logik in Komponenten, wobei der Store nur als „Datensack“ dient. Die Methoden des Stores sind der richtige Ort dafür.
- Ein riesiger Store für die ganze Anwendung. Baue kleine Stores pro Feature und setze sie mit Features zusammen. Das ist leichter zu testen und per Lazy Loading zu laden.
effect()in Komponenten, um Stores miteinander zu synchronisieren. BevorzugewithComputedoderwithLinkedState(siehe effect()).
Spickzettel
signalStore(config?, ...features) config: { providedIn: 'root' } oder nichts (→ providers: [Store])
withState({ ... }) Zustand → Signals: store.x()
withComputed(store => ({ y: computed(...) }))
withMethods((store, dep = inject(X)) => ({ ... })) der einzige Ort, an dem du den Zustand änderst
withHooks({ onInit(store) {}, onDestroy(store) {} })
withProps / withLinkedState Nicht-Zustand-Properties / gekoppelter Zustand (wie linkedSignal)
signalStoreFeature(...features) wiederverwendbare Bausteine: withRequestStatus(), withPagination()
patchState(store, partial | updater, ...) mehrere Updater in einem Aufruf
getState(store) / watchState(store, fn) Snapshot / synchroner Beobachter des Zustands
rxMethod<T>(pipe(...)) aus @ngrx/signals/rxjs-interop — akzeptiert einen Wert, ein Signal oder ein Observable
withEntities<T>() + setAllEntities, addEntity, updateEntity, removeEntity, upsertEntity (@ngrx/signals/entities)
signalState({ ... }) lokaler Zustand in einer Komponente, ohne Store
unprotected(store) aus @ngrx/signals/testing — Zustand in Tests direkt setzen