Un signal este o cutie care ține o valoare și anunță automat pe toți cei care au citit-o atunci când se schimbă. Angular ține evidența: când template-ul, un computed() sau un effect() citește count(), devine dependent de el. Când count se schimbă, doar ce depinde de el se actualizează — fără Zone.js, fără verificarea întregului arbore de componente.
Din v21 aplicațiile noi sunt zoneless, iar din v22 componentele sunt OnPush implicit. Asta înseamnă că semnalele nu mai sunt „o opțiune modernă” — sunt mecanismul principal prin care Angular știe ce să re-randeze. Dacă ții stare în câmpuri simple și le modifici din setTimeout sau din subscribe, UI-ul poate să nu se mai actualizeze.
Cele trei operații de bază
Creezi cu signal(valoareInițială), citești apelând semnalul ca pe o funcție și scrii cu set() sau update(). Starea derivată o faci cu computed().
import { computed, signal, Signal, WritableSignal } from '@angular/core';
const name: WritableSignal<string> = signal('Ana');
name(); // citire → 'Ana'
name.set('Maria'); // scriere directă
name.update((n) => n.toUpperCase()); // scriere pe baza valorii vechi
const greeting: Signal<string> = computed(() => `Salut, ${name()}!`);
greeting(); // 'Salut, MARIA!'
// greeting.set(...) ✗ computed e read-only
const readonlyName: Signal<string> = name.asReadonly(); // expui fără drept de scrieredouble()= 0parity()= parcomputedrecalculat de 1 ori (doar când count s-a schimbat)
Ce trebuie să reții despre computed()
- Lazy: nu se calculează până nu îl citește cineva.
- Memoizat: dacă dependențele nu s-au schimbat, întoarce valoarea din cache — oricât de des îl citești.
- Dependențe dinamice: urmărește doar semnalele citite efectiv la ultima rulare. Într-un
cond() ? a() : b(), cândconde true, schimbările luibsunt ignorate. - Pur: fără efecte secundare (fără HTTP, fără
set()pe alte semnale, fără DOM).
Regula de aur: stare derivată = computed()
Orice valoare care poate fi calculată din altă stare nu se stochează separat. Totalul unui coș, lista filtrată, „este formularul valid”, „are utilizatorul drept de admin” — toate sunt computed(). O singură sursă de adevăr, zero sincronizare manuală.
interface CartItem {
id: number;
name: string;
price: number;
qty: number;
}
export class Cart {
protected readonly items = signal<CartItem[]>([]);
protected readonly filter = signal('');
// ✓ Tot ce se poate calcula din altceva = computed()
protected readonly visible = computed(() =>
this.items().filter((i) => i.name.toLowerCase().includes(this.filter().toLowerCase())),
);
protected readonly subtotal = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.qty, 0),
);
protected readonly vat = computed(() => Math.round(this.subtotal() * 0.19));
protected readonly total = computed(() => this.subtotal() + this.vat());
protected readonly freeShipping = computed(() => this.subtotal() >= 1000);
protected changeQty(id: number, delta: number): void {
this.items.update((items) =>
items.map((i) => (i.id === id ? { ...i, qty: Math.max(0, i.qty + delta) } : i)),
);
}
}| Produs | Preț | Cantitate | Total |
|---|---|---|---|
| Tastatură mecanică | 450 lei | 1 | 450 lei |
| Mouse ergonomic | 220 lei | 0 | 0 lei |
| Monitor 27" | 1300 lei | 0 | 0 lei |
| Hub USB-C | 160 lei | 2 | 320 lei |
// ✓ O singură sursă de adevăr, restul derivat
export class Cart {
readonly items = signal<CartItem[]>([]);
readonly total = computed(() => this.items().reduce((s, i) => s + i.price, 0));
add(item: CartItem) {
this.items.update((list) => [...list, item]);
}
remove(id: number) {
this.items.update((list) => list.filter((i) => i.id !== id));
}
}// ✗ Stare derivată STOCATĂ și sincronizată manual
export class Cart {
items = signal<CartItem[]>([]);
total = signal(0);
add(item: CartItem) {
this.items.update((list) => [...list, item]);
this.total.set(this.total() + item.price); // ușor de uitat, ușor de desincronizat
}
remove(id: number) {
this.items.update((list) => list.filter((i) => i.id !== id));
// bug: am uitat să actualizez total
}
}
// ✗ La fel de rău: un effect care „copiază” o valoare în alt semnal
effect(() => this.total.set(this.items().reduce((s, i) => s + i.price, 0)));Imutabilitate: de ce push() nu funcționează
Un semnal decide dacă s-a schimbat comparând valoarea veche cu cea nouă prin Object.is, adică după referință. Dacă modifici un array sau un obiect pe loc, referința rămâne aceeași și nimeni nu e notificat. Template-ul se poate re-randa întâmplător (de exemplu pentru că ai dat click în componentă), dar computed()-urile rămân cu valoarea veche. Rezultatul: un UI parțial actualizat — cel mai greu bug de găsit.
tags())count = computed(() => tags().length)const tags = signal(['a', 'b']);
// ✗ Mutație: aceeași referință de array → Angular NU află nimic
tags().push('c');
tags.set(tags()); // tot nu ajută: Object.is(vechi, nou) === true
// ✓ Mereu o referință nouă
tags.update((t) => [...t, 'c']); // adăugare
tags.update((t) => t.filter((x) => x !== 'a')); // ștergere
tags.update((t) => t.map((x) => (x === 'b' ? 'B' : x))); // modificare
const user = signal({ name: 'Ana', address: { city: 'Cluj' } });
// ✗ user().address.city = 'Iași';
// ✓
user.update((u) => ({ ...u, address: { ...u.address, city: 'Iași' } }));Egalitate custom cu equal
Uneori primești un obiect nou care e „același” din punct de vedere logic (de exemplu de la un API sau dintr-un formular). Cu opțiunea equal eviți notificări și recalculări inutile.
import { signal } from '@angular/core';
interface Filters {
query: string;
page: number;
}
// Implicit: egalitate prin Object.is (referință).
// Cu `equal` decizi tu când două valori sunt „la fel” → nu notifică degeaba.
const filters = signal<Filters>(
{ query: '', page: 1 },
{ equal: (a, b) => a.query === b.query && a.page === b.page },
);
filters.set({ query: '', page: 1 }); // obiect nou, dar „egal” → NIMENI nu e notificat
// Merge și pe computed:
const ids = computed(() => users().map((u) => u.id), {
equal: (a, b) => a.length === b.length && a.every((id, i) => id === b[i]),
});{ query: 'angular', page: 1 }signal(filters)1încărcări ale listei
- încărcarea #1 · pagina 1
signal(filters, { equal })1încărcări ale listei
- încărcarea #1 · pagina 1
untracked(): citire fără dependență
Într-un computed sau effect, orice semnal citit devine dependență. Când vrei doar valoarea curentă, fără să re-rulezi la schimbarea ei, folosește untracked().
import { effect, signal, untracked } from '@angular/core';
const user = signal('Ana');
const theme = signal('dark');
effect(() => {
// Dependență: user — efectul rulează din nou când user se schimbă
const name = user();
// Citire FĂRĂ dependență: schimbarea temei NU re-rulează efectul
const currentTheme = untracked(theme);
console.log(`[analytics] ${name} a deschis pagina (tema: ${currentTheme})`);
});
// untracked acceptă și o funcție — util când apelezi cod care citește semnale intern
effect(() => {
const id = selectedId();
untracked(() => this.logger.log(`Selectat ${id}`)); // logger-ul poate citi orice semnal
});const t = theme();rulări: 1- Ana a deschis pagina (tema: dark)
const t = untracked(theme);rulări: 1- Ana a deschis pagina (tema: dark)
Semnale în servicii: scriere privată, citire publică
Pattern-ul de bază pentru orice stare partajată: semnalul scriibil e private, în afară expui asReadonly() și computed()-uri, iar modificările trec prin metode cu nume clare. Detalii complete în pagina State management.
import { Service, computed, signal } from '@angular/core';
@Service()
export class CounterStore {
// Scrierea e privată: doar store-ul își modifică starea…
private readonly _count = signal(0);
// …iar în afară expui doar citire.
readonly count = this._count.asReadonly();
readonly isZero = computed(() => this._count() === 0);
increment(): void {
this._count.update((c) => c + 1);
}
}Când DA, când NU
- Starea unei componente (filtre, selecție, toggle-uri, pagina curentă).
- Starea partajată dintr-un serviciu / store.
- Orice valoare afișată în template.
- Valori derivate: totaluri, liste filtrate, flag-uri (
computed). - Inputurile componentelor (
input()e tot un semnal).
- Fluxuri de evenimente în timp (websocket, drag, tastare cu operatori complecși) — acolo RxJS e mai expresiv. Vezi Signals ↔ RxJS.
- Date asincrone de la server — folosește resource / httpResource, nu un semnal setat manual din
subscribe. - Constante care nu se schimbă niciodată — un
readonlysimplu e suficient. - Stare derivată „editabilă” (selecție care se resetează) — folosește linkedSignal.
Capcane frecvente
- Uiți parantezele:
{{ count }}afișează funcția, nu valoarea. Corect:{{ count() }}. - Mutezi în loc să înlocuiești:
push,splice,obj.x = 1nu notifică. - Efecte secundare în computed: un
computedcare face HTTP sau setează alt semnal e un bug în așteptare. - Async în computed:
computed(async () => ...)întoarce un Promise, nu valoarea. Foloseșteresource(). - Citești o singură dată:
const x = this.count();în constructor e o fotografie, nu o legătură reactivă. - Sincronizezi cu effect:
effect(() => b.set(a() * 2))→ foloseștecomputed(saulinkedSignaldacă b trebuie să fie și scriibil).
Fișă rapidă
// CREARE
const a = signal(0); // WritableSignal<number>
const b = signal<User | null>(null); // tip explicit când pornești cu null
const c = computed(() => a() * 2); // Signal<number>, read-only, lazy, memoizat
// CITIRE
a(); // în TS și în template: {{ a() }}
// SCRIERE
a.set(5);
a.update((v) => v + 1);
// EXPUNERE SIGURĂ
readonly value = this._value.asReadonly();
// OPȚIUNI
signal(x, { equal: (p, n) => p.id === n.id, debugName: 'selected' });
// CITIRE FĂRĂ DEPENDENȚĂ
untracked(a); untracked(() => a() + b());
// VERIFICĂRI
isSignal(x); isWritableSignal(x);