NgRx SignalStore (pachetul @ngrx/signals) e un mod declarativ de a construi store-uri pe semnale. Compui funcționalități (withState, withComputed, withMethods, withHooks, withEntities…), iar rezultatul e o clasă injectabilă. Starea ei e expusă ca semnale și poate fi modificată doar prin metodele pe care le definești tu.
E pasul următor după store-ul „de mână” de pe pagina State management: aceleași idei, dar cu structură standard, entități gata făcute, integrare RxJS pentru fluxuri asincrone și bucăți refolosibile între store-uri. Versiunea folosită aici e @ngrx/signals 22, compatibilă cu Angular 22.
Anatomia unui store
Un signalStore(...) e o listă de features aplicate în ordine. Fiecare feature vede ce au adăugat cele de dinaintea lui. withState definește starea: fiecare proprietate devine un semnal (store.todos()), iar obiectele imbricate devin deep signals. withComputed adaugă stare derivată, withMethods adaugă operațiile. Doar metodele pot apela patchState. Starea e protejată implicit, deci din afara store-ului nu o poți modifica direct.
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' }, // fără opțiune: îl pui în `providers` unde ai nevoie
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 primește fie un obiect parțial ({ filter }), fie o funcție (state) => Partial<State>. Primește și oricâte updatere, aplicate împreună. Starea e imutabilă: întorci mereu obiecte și array-uri noi, altfel semnalele nu observă schimbarea (aceeași regulă ca la signal()).
Unde trăiește store-ul
Ca orice serviciu, durata de viață a store-ului e dată de injectorul în care îl pui. Cu { providedIn: 'root' } ai o singură instanță globală, bună pentru sesiunea utilizatorului sau un coș de cumpărături. Fără opțiune, îl pui în providers-ul unei componente sau al unei rute și primești o instanță nouă, distrusă odată cu ea. E alegerea potrivită pentru starea unei pagini.
// 1. Global: o singură instanță pentru toată aplicația
export const SessionStore = signalStore({ providedIn: 'root' }, withState({ user: null as User | null }));
// 2. Per pagină / per componentă: instanță nouă, distrusă odată cu componenta
export const ProductsStore = signalStore(withEntities<Product>() /* ... */);
@Component({
providers: [ProductsStore], // fiecare <app-catalog> are store-ul lui
template: `...`,
})
export class Catalog {
readonly store = inject(ProductsStore);
}
// 3. Per rută (feature): trăiește cât trăiește ruta
{ path: 'catalog', providers: [ProductsStore], loadComponent: () => import('./catalog') }Entități
withEntities<Product>() adaugă o colecție normalizată: în stare ai ids și entityMap, iar ca semnal ai entities(), un array derivat. Updaterele din @ngrx/signals/entities (setAllEntities, addEntity, updateEntity, upsertEntity, removeEntity…) se dau direct lui patchState. Pentru mai multe colecții în același store există colecții cu nume (withEntities({ entity: type<Product>(), collection: 'product' }) → productEntities()).
Fluxuri asincrone cu rxMethod
rxMethod (din @ngrx/signals/rxjs-interop) transformă un pipe RxJS într-o metodă. O poți apela cu o valoare, cu un Observable sau cu un semnal. Cu semnal, metoda rulează la fiecare schimbare a lui. Apelul store.search(store.query) din onInit leagă căutarea de query o singură dată. Restul e RxJS obișnuit: debounceTime, switchMap ca să anulezi cererea veche, catchError în interiorul lui switchMap, ca o eroare să nu oprească fluxul (vezi RxJS avansat). Subscripția se închide singură când e distrus store-ul.
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>(), // stare: ids + entityMap; semnal: entities()
withState({ query: '' }),
withRequestStatus(), // feature custom (mai jos)
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('Serverul nu răspunde.'));
return EMPTY; // stream-ul exterior rămâne viu
}),
),
),
),
),
})),
withHooks({
onInit(store) {
store.search(store.query); // rxMethod primește un SEMNAL: rulează la fiecare schimbare
},
}),
);tapResponse în loc de tap + catchError. Face același lucru mai compact, dar vine din pachetul separat @ngrx/operators. Demo-ul de aici folosește doar RxJS.Features refolosibile
signalStoreFeature(...) grupează mai multe features într-o funcție pe care o adaugi în orice store, de exemplu withRequestStatus(): starea cererii plus isPending și error. Updaterele sunt funcții simple care întorc o bucată de stare, deci le combini cu altele în același patchState. Așa elimini copy-paste-ul de loading/error din fiecare store.
import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withState } from '@ngrx/signals';
export type RequestStatus = 'idle' | 'pending' | 'fulfilled' | { error: string };
// O bucată de store refolosibilă: stare + computed-uri, compusă în orice signalStore
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;
}),
})),
);
}
// Updatere: funcții simple care întorc o parte din stare → 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 });- Niciun produs.
{
"query": "",
"inStockOnly": false,
"cart": 0,
"requestStatus": "idle",
"ids": []
}Restul cutiei de unelte
signalState e varianta „fără store”: un obiect de semnale pentru starea locală a unei componente, tot cu patchState. withProps adaugă proprietăți care nu sunt stare (servicii, resurse, observable-uri). withLinkedState face pentru store ce face linkedSignal pentru o componentă. getState îți dă un instantaneu al stării, iar watchState te anunță sincron la fiecare schimbare. Pentru teste, unprotected(store) din @ngrx/signals/testing îți permite să pui store-ul într-o stare anume. Pachetul are și un plugin de evenimente (@ngrx/signals/events), pentru stilul Redux cu evenimente și reducere.
import { patchState, signalState } from '@ngrx/signals';
// Pentru starea locală a UNEI componente: fără store, doar un obiect de semnale
@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 }));
}
// în template: state.query(), state.page()
}Când DA, când NU
- Stare de feature folosită de mai multe componente, cu operații asincrone (căutare, salvare, paginare).
- Colecții de entități actualizate des (liste editabile, coșuri, tabele).
- Echipe mai multe care vor aceeași formă pentru toate store-urile și features refolosibile.
- Ai NgRx Store clasic și vrei ceva mai ușor pentru feature-urile noi. Pot conviețui.
- Starea unei singure componente:
signal()sausignalState. - Date citite de la server și doar afișate:
httpResource(vezi resource). - Un serviciu mic cu două semnale și trei metode: un store de mână e mai clar pentru toată lumea.
- Aplicații în care nimeni din echipă nu cunoaște NgRx și termenul e mâine. Dependența are un cost de învățare.
| Store de mână | SignalStore | NgRx Store (Redux) | |
|---|---|---|---|
| Boilerplate | minim | mic | mare (actions, reducers, effects, selectors) |
| Structură standard | ce decide echipa | da | da, foarte strictă |
| Entități | scrii singur | withEntities | @ngrx/entity |
| Async | resource / RxJS | rxMethod, resource | effects |
| Bun pentru | feature-uri mici | majoritatea aplicațiilor | fluxuri complexe, audit de evenimente |
Capcane frecvente
- Mutarea stării (
s.todos.push(x)în updater). Semnalul primește aceeași referință și nimic nu se actualizează. Întoarce mereu array-uri și obiecte noi. providedIn: 'root'pentru starea unei pagini. Starea „rămâne” de la o vizită la alta și se amestecă între tab-uri de componente. Pune store-ul înproviders-ul paginii.catchErrorpus dupăswitchMapînrxMethod. Prima eroare închide fluxul și căutarea nu mai funcționează. Pune-l în fluxul interior.- Logică de business în componente, cu store-ul folosit doar ca „sac de date”. Metodele store-ului sunt locul ei.
- Un store uriaș pentru toată aplicația. Fă store-uri mici, pe feature, și compune-le cu features. Așa e mai ușor de testat și de încărcat lazy.
effect()în componente ca să sincronizezi store-uri între ele. PreferăwithComputedsauwithLinkedState(vezi effect()).
Fișă rapidă
signalStore(config?, ...features) config: { providedIn: 'root' } sau nimic (→ providers: [Store])
withState({ ... }) stare → semnale: store.x()
withComputed(store => ({ y: computed(...) }))
withMethods((store, dep = inject(X)) => ({ ... })) singurul loc din care modifici starea
withHooks({ onInit(store) {}, onDestroy(store) {} })
withProps / withLinkedState proprietăți non-stare / stare legată (ca linkedSignal)
signalStoreFeature(...features) bucăți refolosibile: withRequestStatus(), withPagination()
patchState(store, partial | updater, ...) mai multe updatere într-un singur apel
getState(store) / watchState(store, fn) instantaneu / observator sincron al stării
rxMethod<T>(pipe(...)) din @ngrx/signals/rxjs-interop — primește valoare, semnal sau Observable
withEntities<T>() + setAllEntities, addEntity, updateEntity, removeEntity, upsertEntity (@ngrx/signals/entities)
signalState({ ... }) stare locală într-o componentă, fără store
unprotected(store) din @ngrx/signals/testing — setezi starea direct în teste