Mit Signals brauchen die meisten Anwendungen keine State-Management-Bibliothek mehr. Ein Service mit ein paar privaten Signals, computed()-Selektoren und Methoden mit klaren Namen deckt fast jeden Fall ab. Die wichtige Frage lautet nicht „welche Bibliothek“, sondern wo jedes Stück Zustand lebt. Wenn ein Bildschirm immer wieder Kombinationen erreicht, die es nicht geben dürfte, ist die Antwort eine Zustandsmaschine.
Die Ebenen des Zustands
Art des Zustands Beispiel Wo du ihn hältst
────────────────────────────────────────────────────────────────────────────────
Lokale UI offenes Dropdown, Hover, Tab signal() in der Komponente
Formular Felder, Validierung, dirty Signal Forms (form())
Feature Filter, Auswahl, Liste @Injectable()-Store auf Route/Seite
Server Produkte, Bestellungen resource / httpResource (im Store)
URL Suche, Seite, teilbarer Filter Query-Parameter + input() (Router)
Global Nutzer, Warenkorb, Theme, Sprache @Service() im RootHalte den Zustand auf der niedrigsten Ebene, auf der er gebraucht wird. Hebe ihn (Komponente → Feature → global) nur an, wenn ein neuer Konsument ihn braucht.
Das Grundmuster: ein Store auf Signals
@Injectable() // von der Seite / Route des Features bereitgestellt
export class TodoStore {
// 1. ZUSTAND — privat, beschreibbar
private readonly _todos = signal<Todo[]>([]);
private readonly _filter = signal<TodoFilter>('all');
// 2. SELEKTOREN — öffentlich, nur lesbar
readonly todos = this._todos.asReadonly();
readonly filter = this._filter.asReadonly();
readonly visible = computed(() => {
const f = this._filter();
return this._todos().filter((t) => f === 'all' || (f === 'done' ? t.done : !t.done));
});
readonly remaining = computed(() => this._todos().filter((t) => !t.done).length);
// 3. AKTIONEN — Methoden mit fachlichen Namen, immutable Updates
add(title: string) {
this._todos.update((list) => [...list, { id: Date.now(), title, done: false }]);
}
toggle(id: number) {
this._todos.update((list) => list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
setFilter(filter: TodoFilter) {
this._filter.set(filter);
}
}Der Filter steht auch in der URL (?filtru=…) — lade die Seite neu: Sowohl die Liste (localStorage) als auch der Filter (URL) bleiben erhalten.
Server-Zustand
Daten vom Server sind nicht „dein Zustand“ — sie sind ein Cache des Server-Zustands. Halte sie in httpResource/resource innerhalb des Stores, und lade sie nach Schreibzugriffen neu oder aktualisiere sie lokal.
// Ein Store mit Server-Daten: resource zum Lesen, HttpClient zum Schreiben
@Service()
export class ProductsStore {
private readonly api = inject(ProductsApi);
readonly query = signal('');
readonly page = linkedSignal({ source: this.query, computation: () => 1 });
readonly products = httpResource<PageDto<Product>>(
() => ({ url: '/api/products', params: { q: this.query(), page: this.page() } }),
{ defaultValue: { items: [], total: 0 } },
);
readonly isEmpty = computed(() => !this.products.isLoading() && this.products.value().items.length === 0);
async remove(id: number) {
await firstValueFrom(this.api.delete(id));
this.products.reload();
}
}Auch die URL ist Zustand
// Zustand, der einen Refresh überleben / als Link versendbar sein muss → die URL
// mit withComponentInputBinding() landen Query-Parameter direkt in Inputs:
@Component({ /* geroutet unter /products?q=laptop&page=2 */ })
export class ProductsPage {
readonly q = input<string>(''); // ?q=
readonly page = input(1, { transform: numberAttribute }); // ?page=
private readonly router = inject(Router);
search(q: string) {
this.router.navigate([], { queryParams: { q, page: null }, queryParamsHandling: 'merge' });
}
}Facade
Kombiniert eine Seite Daten aus mehreren Stores, bündelt eine Facade sie in einem einzigen „View Model“. Die Komponente bleibt schlank, und die Orchestrierung hat einen klaren Platz.
// Facade: ein einziger Einstiegspunkt für die Seite, der mehrere Quellen verbirgt
@Injectable()
export class CheckoutFacade {
private readonly cart = inject(CartStore);
private readonly user = inject(AuthStore);
private readonly shipping = inject(ShippingStore);
readonly vm = computed(() => ({
items: this.cart.items(),
total: this.cart.total(),
address: this.user.current()?.address,
shippingOptions: this.shipping.options.value(),
canPay: this.cart.items().length > 0 && !!this.user.current(),
}));
pay() { /* Orchestrierung zwischen Stores */ }
}
// die Komponente: @let vm = facade.vm(); — ein Objekt, leicht zu testenUnd NgRx?
// Wenn das Team Konventionen + Plugins will: NgRx SignalStore (@ngrx/signals)
export const TodoStore = signalStore(
withState({ todos: [] as Todo[], filter: 'all' as TodoFilter }),
withComputed(({ todos, filter }) => ({
visible: computed(() => todos().filter(/* ... */)),
})),
withMethods((store) => ({
add(title: string) {
patchState(store, (s) => ({ todos: [...s.todos, { id: Date.now(), title, done: false }] }));
},
})),
);
// + withEntities, withHooks, Devtools, rxMethod für asynchrone Effekte- Die meisten Anwendungen, auch große.
- Teams, die einfachen Code ohne Boilerplate bevorzugen.
- Feature-Zustand, der an Seiten gebunden ist.
- SignalStore: Du willst vorgegebene Konventionen, Entities, Hooks, wiederverwendbare Plugins.
- NgRx Store (Redux): Du brauchst ein Action-Log, Time-Travel, Event Sourcing, viele Teams auf demselben Zustand.
- Die Anwendung nutzt bereits NgRx — bleib konsistent.
Häufige Stolperfallen
- Alles global: Seitenzustand im Root → alte Filter tauchen auf, wenn du zurückkehrst, der Speicherverbrauch wächst. Stelle den Store auf der Route/Seite bereit.
- Öffentliche beschreibbare Signals in einem Store → jeder kann den Zustand von überall ändern. Exponiere
asReadonly(). - Duplizierter Zustand (dieselbe Liste in zwei Stores) → Desynchronisation. Eine Quelle, der Rest abgeleitet.
- Stores, die sich gegenseitig zirkulär injizieren → ein Zeichen, dass ein Konzept verschoben werden muss (oder dass du eine Facade brauchst).