Cu semnale, majoritatea aplicațiilor nu mai au nevoie de o librărie de state management. Un serviciu cu câteva semnale private, selectori computed() și metode cu nume clare acoperă aproape orice caz. Întrebarea importantă nu e „ce librărie”, ci unde trăiește fiecare bucată de stare. Când un ecran tot ajunge în combinații care n-ar trebui să existe, răspunsul e o mașină de stări.
Nivelurile stării
Tip de stare Exemplu Unde o ții
────────────────────────────────────────────────────────────────────────────────
UI local dropdown deschis, hover, tab signal() în componentă
Formular câmpuri, validare, dirty Signal Forms (form())
Feature filtre, selecție, listă store @Injectable() pe rută/pagină
Server produse, comenzi resource / httpResource (în store)
URL căutare, pagină, filtru partajabil query params + input() (router)
Global user curent, coș, temă, limbă @Service() în rootȚine starea la nivelul cel mai de jos la care e nevoie de ea. Ridic-o (componentă → feature → global) doar când un consumator nou o cere.
Pattern-ul de bază: store pe semnale
@Injectable() // furnizat de pagina / ruta feature-ului
export class TodoStore {
// 1. STARE — privată, scriibilă
private readonly _todos = signal<Todo[]>([]);
private readonly _filter = signal<TodoFilter>('all');
// 2. SELECTORI — publici, read-only
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. ACȚIUNI — metode cu nume de domeniu, update-uri imutabile
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);
}
}Filtrul e și în URL (?filtru=…) — reîncarcă pagina: și lista (localStorage) și filtrul (URL) rămân.
Starea de server
Datele de la server nu sunt „starea ta” — sunt un cache al stării serverului. Ține-le în httpResource/resource în store, iar după scrieri reîncarcă sau actualizează local.
// Store cu date de la server: resource pentru citire, HttpClient pentru scriere
@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();
}
}URL-ul e și el stare
// Starea care trebuie să supraviețuiască unui refresh / să poată fi trimisă ca link → URL
// cu withComponentInputBinding(), query params ajung direct în input-uri:
@Component({ /* rutat la /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
Când o pagină combină date din mai multe store-uri, un facade le adună într-un singur „view model”. Componenta rămâne subțire, iar orchestrarea are un loc clar.
// Facade: un singur punct de intrare pentru pagină, ascunde mai multe surse
@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() { /* orchestrare între store-uri */ }
}
// componenta: @let vm = facade.vm(); — un singur obiect, ușor de testatȘi NgRx?
// Când echipa vrea convenții + plugin-uri: 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 pentru efecte asincrone- Majoritatea aplicațiilor, inclusiv mari.
- Echipe care preferă cod simplu, fără boilerplate.
- Stare de feature, legată de pagini.
- SignalStore: vrei convenții impuse, entități, hooks, plugin-uri reutilizabile.
- NgRx Store (Redux): ai nevoie de log de acțiuni, time-travel, event sourcing, echipe multe pe aceeași stare.
- Aplicația folosește deja NgRx — păstrează consecvența.
Capcane frecvente
- Totul global: stare de pagină în root → filtrele vechi apar când revii, memoria crește. Furnizează store-ul pe rută/pagină.
- Semnale publice scriibile în store → oricine poate modifica starea de oriunde. Expune
asReadonly(). - Stare duplicată (aceeași listă în două store-uri) → desincronizare. O singură sursă, restul derivat.
- Store-uri care se injectează circular → semn că un concept trebuie mutat (sau că îți trebuie un facade).