technologytutorial
Signals mélyen — signal, computed, effect, és a reactive graph
Stack:
Mit tanulsz
- signalsBevezető
Mit tanulsz meg ebből a cikkből?
Megérted a reactive graph fogalmát, azt, hogy mi a dependency tracking és miért automatikus. Látni fogod, miért lusta a
computed, mikor fut az effect, miben különbözik egymástól a három alap-primitív. Megismered a linkedSignal-t, az untracked szerepét, az equal függvényt, és azt, hogy pontosan mikor — és mi alapján — értesülnek a fogyasztók a változásokról.01 — Alapfogalmak
Mi az a reactive primitív?
Három szó, amit a cikkben újra és újra látni fogsz — és pontosan ezeket érdemes tisztázni először.
A signal egy úgynevezett reactive primitív. A „primitív" itt azt jelenti: ez a legkisebb építőkocka, amire az egész reaktivitás épül. Nincs alatta réteg — ha valamit a signal nem tud, akkor az Angular egyszerűen nem tud róla. Három fogalom jelenik meg mindenhol, és érdemes őket előre elkülöníteni:
- Producer (termelő) — az az objektum, ami értéket szolgáltat. Egy
signal()például producer. - Consumer (fogyasztó) — aki olvassa a producer értékét. Egy
computed()vagy egyeffect()fogyasztó (is). - Reactive graph — a producerek és fogyasztók közti élő függőségi hálózat. Ebben minden élt futás közben, automatikusan rögzít az Angular.
A
computed egy érdekes hibrid: egyszerre producer és consumer. Consumer, mert más signalokat olvas. Producer, mert mások meg őt olvassák. Ez a duális szerep az, ami lehetővé teszi, hogy egész láncokat építhetsz — az egyik computed egy másik computed eredménye, aminek inputja egy harmadik signal, és így tovább.Ez a graph rendkívül dinamikus. Nem te deklarálod előre, ki kitől függ. Az Angular futás közben tanulja meg a függőségeket, és ha egy
computed egyik ága egy if miatt nem fut le, az abban olvasott signalra a computed nem is lesz ráhangolódva. Ezt hívják dynamic dependency tracking-nek, és ez a legnagyobb különbség a signal-alapú modellek és a régi RxJS-stílusú stream-ek között.Mit tanulsz
- signalsBevezető
02 — signal()
A signal — érték, aki tudja, kik figyelik
Mindennek az alapja. Mutálható érték, aminek egy oldalkönyve van a fogyasztóiról.
A
signal() egy érték-tartó függvény. Kap egy kezdőértéket, és visszaad egy olyan függvényt, amivel az értéket olvasni lehet. Az értéket a set(), update(), és — objektumok esetén — a mutate() metódusokkal lehet módosítani. Ha olvasod a signalt egy olyan kontextusból, amiből az Angular tudja, hogy az egy reaktív kontextus (computed, effect, vagy template), akkor a signal regisztrálja az olvasót a fogyasztói közé.signal basicstypescript
import { signal } from '@angular/core';
// Create — the signal() factory returns a WritableSignal
const count = signal(0);
// Read — call it as a function
console.log(count()); // 0
// Write — set a new value
count.set(5);
// Write — based on the previous value
count.update(c => c + 1); // 6
// Object signal — update with a fresh reference
const user = signal({ name: 'Zsaga', age: 45 });
user.update(u => ({ ...u, age: u.age + 1 }));
Referencia-egyenlőség
Ez egy fontos részlet: a signal alapértelmezetten
Object.is-t használ a régi és új érték összehasonlítására. Ha ugyanazt a referenciát adod át, a signal nem értesíti a fogyasztóit. Ez optimalizáció, nem hiba — de első pillanatban meglepő tud lenni:common pitfalltypescript
const items = signal([1, 2, 3]);
// DOES NOT NOTIFY — same reference
items.update(arr => {
arr.push(4);
return arr;
});
// NOTIFIES — new array
items.update(arr => [...arr, 4]);
Ha valamiért mutálni akarsz és mégis értesítést szeretnél, vagy egyedi logikával akarod eldönteni, hogy a két érték „egyenlő"-e, add át az
equal opciót:custom equaltypescript
const position = signal({ x: 0, y: 0 }, {
equal: (a, b) => a.x === b.x && a.y === b.y
});
// New object, same content — does not notify
position.set({ x: 0, y: 0 }); // no-op
position.set({ x: 1, y: 0 }); // notifies
Mindig immutable módon írj
A mutálás nem csak a signal fogyasztóit csapja be — az OnPush stratégiát is. Az új referenciás frissítés az Angular-világ egyik alappillére. Ha ezt következetesen csinálod, a signal automatikusan jól fog működni, és nem kell
equal-lal bohóckodnod.Mit tanulsz
- computedKözéphaladó
03 — computed()
A computed — lusta, memoizált, okos
A computed a reactive graph workhorse-a. Három tulajdonsága van, ami nélkül nem értenéd meg — és mind a három nagyon fontos.
A
computed() egy olyan signal, aminek értéke más signalokból számítódik. Nem kap „írást" — csak egy számoló függvényt, ami az aktuális értéket előállítja. Ha a függvény által olvasott signalok közül bármelyik változik, a computed érvénytelenné válik, és a következő olvasáskor újraszámítódik.computed basicstypescript
import { signal, computed } from '@angular/core';
const firstName = signal('Zsaga');
const lastName = signal('Soft');
const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // 'Zsaga Soft'
lastName.set('Studio');
console.log(fullName()); // 'Zsaga Studio'
A computed három fontos tulajdonsága:
1. Lusta
A computed nem fut le, amíg valaki nem olvassa. Ha létrehozol egy computed-ot, ami egy bonyolult számítást végez, de sehol nem olvasod, a számítás soha nem fog megtörténni. Ez alapvetően eltér az RxJS-től, ahol egy
combineLatest vagy map azonnal reagál a source változására — a signal csak akkor, ha szükség van rá.2. Memoizált
A számítás eredményét a computed cache-eli. Ha tízszer olvasod ki, de közben egyik forrás-signal sem változott, a függvény egyszer fut le, és tízszer ugyanazt az értéket kapod.
3. Dinamikus függőségek
A computed függőségei minden futáskor újraregisztrálódnak. Ha a függvényed egy
if miatt nem olvassa az egyik signalt, arra a számításra az a signal nem lesz hatással. Ez nem hiba — ez egy hatalmas optimalizációs lehetőség.dynamic dependenciestypescript
const isPremium = signal(false);
const basePrice = signal(100);
const premiumDiscount = signal(20);
const finalPrice = computed(() => {
if (!isPremium()) {
return basePrice();
// premiumDiscount NOT READ — the computed doesn't track its changes
}
return basePrice() - premiumDiscount();
});
Amíg az
isPremium hamis, a premiumDiscount változtatása semmit nem vált ki. Amint igazra állítod, a computed újraszámolódik, és most már olvasta a premiumDiscount-ot is — onnantól kezdve annak változása triggereli az újraszámítást.A computed függvényének tisztának kell lennie
A computed függvénye nem módosíthat állapotot, nem hívhat HTTP-t, nem írhat console-ra (legalábbis ne várd el, hogy ez megbízhatóan megtörténjen). Ha a function fut, ha nem — ez a függvény hatóköre. Minden mellékhatás az
effect()-be való. Ha ezt megszeged, kiszámíthatatlan kód lesz a vége.Mit tanulsz
- effectKözéphaladó
04 — effect()
Az effect — amikor mellékhatás kell
Az effect a hely, ahol a reactive graph találkozik a külvilággal — logolás, szinkronizáció, subscribe-ok.
Míg a computed az értékek világában él, az
effect() a mellékhatások világába vezet. Egy effect egy függvény, ami lefut, amikor létrehozzák, és minden alkalommal újra, amikor bármely általa olvasott signal változik. Az effect a helyes eszköz a külvilággal való szinkronizálásra — logolás, localStorage-ba írás, DOM manipuláció (nagyon ritkán), vagy akár egy RxJS subject-be emittálás.effect examplestypescript
import { Component, signal, effect } from '@angular/core';
@Component({ /* ... */ })
export class SettingsComponent {
readonly theme = signal<'light' | 'dark'>('light');
constructor() {
// localStorage sync — runs on every theme change
effect(() => {
localStorage.setItem('theme', this.theme());
});
}
}
Három dolog érdemes megjegyezni az effect-ről:
- Azonnal lefut, ha létrehozzák — az első futás része a szerződésnek. Ez biztosítja, hogy a függőségeket regisztrálja.
- Aszinkron ütemezésben — az effect nem azonnal reagál a signal változására, hanem „hamarosan", microtask-ban vagy a következő CD ciklusban. Ez glitch-mentesség miatt van így (erről mindjárt).
- Automatikusan feltakarítódik — ha a komponensben jön létre, a komponens megszűnésekor az effect is leáll. Ezért kell a
constructor-ban vagyinject()-es kontextusban létrehozni.
onCleanup — erőforrás-felszabadítás
Az effect függvénye kap egy
onCleanup callback-et, amivel regisztrálhatsz tisztító logikát — ez a következő futás előtt vagy a leállításkor fut:effect cleanuptypescript
effect((onCleanup) => {
const id = this.selectedId();
const ws = new WebSocket(`wss://api/stream/${id}`);
ws.addEventListener('message', handleMessage);
onCleanup(() => ws.close());
});
Ha a
selectedId változik, előbb lefut a ws.close(), majd új WebSocket nyílik az új ID-val. Nincs leak, nincs boilerplate, nincs takeUntilDestroyed.Ne írj signalt effect-ben
Az effect-ben signal-írás alapból hibát dob. Ez szándékos: az ilyen „effect frissíti signalt" pattern gyakran végtelen ciklust hoz létre, és az állapot eredetét is nehéz követni. Ha tényleg szükséged van rá (pl. cascading derived state), használj
allowSignalWrites: true flag-et — de gondold át, nem inkább egy computed-ot akartál.Mit tanulsz
- signalsKözéphaladó
05 — Három összehasonlítása
signal vs computed vs effect
Három mondat, ami tisztázza, mikor melyiket használd.
signal()— Producer: érték, amit írsz. Állapot forrása.computed()— Producer + Consumer: érték, amit számolsz. Lusta, memoizált, tiszta függvény.effect()— Consumer: kód, ami reagál. Mellékhatás a külvilág felé.
Ha az értékre kíváncsi a UI, az mindig computed, nem effect. Az effect akkor jön képbe, ha a signal-változáskor valamit ki kell vinni a reactive graph-ból — localStorage-ba, websocketre, logba, API-hívásba.
Egy konkrét döntési példa
Rossz — effect-tel
A
Két gond: a fullName egy-egy frame-re elavult marad, és nehéz követni, honnan származik az értéke.
fullName egy közönséges field. Egy effect hallgatózik a firstName és lastName-re, és mindkét változáskor frissíti.Két gond: a fullName egy-egy frame-re elavult marad, és nehéz követni, honnan származik az értéke.
Jó — computed-dal
A
Előny: egyetlen helyen van az összekötő logika, és a számítás csak akkor fut le, ha tényleg szükség van rá.
fullName egy computed, amit a firstName és lastName-ből származtatunk. Amikor olvasod, garantáltan friss.Előny: egyetlen helyen van az összekötő logika, és a számítás csak akkor fut le, ha tényleg szükség van rá.
Mit tanulsz
- untrackedHaladó
06 — untracked
untracked — kibújni a függőségek alól
Vannak esetek, amikor egy signalt olvasni akarsz, de nem szeretnéd, hogy a computed vagy effect rá hangolódjon.
Előfordul, hogy az effect vagy computed függvényében egy olyan signalt olvasol, ami csak „kontextus" — nem akarod, hogy az effect minden alkalommal lefusson, ha ez a signal változik. Erre van az
untracked():untracked exampletypescript
import { effect, signal, untracked } from '@angular/core';
const user = signal({ id: 'u1', name: 'Zsaga' });
const message = signal('hello');
effect(() => {
const msg = message(); // registers: message → effect
const u = untracked(() => user()); // NOT registered
console.log(`${u.name}: ${msg}`);
});
Ebben a példában az effect minden
message-változáskor lefut, és olvassa a friss user objektumot is — de ha csak a user változik, az effect nem indul. A felhasználó nevét csak „kontextusként" használjuk, nem ez a fő trigger.Az
untracked tipikus use case-ei: amikor a „ki is kezdeményezte a változást" info kell logoláshoz, vagy amikor egy analytics event-et lősz el, de nem akarod, hogy a session ID változásakor újralőjjön.Mit tanulsz
- linked-signalHaladó
07 — linkedSignal
linkedSignal — alap érték, de felülírható
Van egy speciális eset, amire sem signal, sem computed nem ideális. A v20-ban érkezett linkedSignal oldja meg.
Tegyük fel, van egy terméklistád, és egy „kiválasztott termék" signal, amit a felhasználó tud állítgatni. Viszont, ha a terméklista frissül — például új szűrés után —, a kiválasztást vissza szeretnéd állítani az első elemre. Egy sima signal nem tud ilyet, egy computed viszont nem engedi, hogy a felhasználó kézzel beállítsa.
linkedSignaltypescript
import { signal, linkedSignal } from '@angular/core';
const products = signal([
{ id: 'p1', name: 'Apple' },
{ id: 'p2', name: 'Banana' }
]);
// linkedSignal: defaults to the first product, but can be set manually
const selected = linkedSignal(() => products()[0]);
selected.set(products()[1]); // Banana
// products updates — selected falls back to the linked computation
products.set([{ id: 'p3', name: 'Cherry' }]);
selected(); // Cherry — the first item of the new list
A
linkedSignal tehát egyszerre writable signal és computed. Van alapérték, amit egy derived függvény állít elő, de a felhasználó is tudja set()-elni. Amikor a forrás-signal változik, a user-override felülíródik — kivéve, ha megadsz egy „equality" függvényt, ami azt mondja, hogy a régi override még érvényes az új kontextusban.Mit tanulsz
- reactive-graphHaladó
08 — Glitch-mentesség
Miért nem látsz inkonzisztens állapotot?
A reactive rendszerek nagy csapdája az átmeneti inkonzisztencia. Az Angular signal-motorja ezt mérnöki szinten kizárja.
Képzeld el a graph-ot: van
firstName és lastName signal, és két computed épül rájuk — fullName és initials. Egy effect mindkettőt olvassa, és logol:glitch scenariotypescript
const firstName = signal('Zsaga');
const lastName = signal('Soft');
const fullName = computed(() => `${firstName()} ${lastName()}`);
const initials = computed(() => firstName()[0] + lastName()[0]);
effect(() => console.log(fullName(), initials()));
firstName.set('Zoli');
lastName.set('Kovacs');
Egy naív rendszerben az effect többször futna: egyszer a
firstName változása miatt, egyszer a lastName változása miatt. Az első futáskor a fullName-ben „Zoli Soft" szerepelne — egy átmeneti, nem létező állapot. Ezt hívják glitch-nek.Az Angular signal-motorja ezt push/pull hibrid modellel oldja meg. Amikor egy signal változik, csak egy érvénytelenítés-hullám terjed végig a graph-on — a computed-ok nem számolnak újra, csak megjelölik magukat „dirty"-nek. A consumer (az effect) aszinkron, batched módon van ütemezve, és csak akkor fut le, amikor az összes változás beérkezett. A futáskor pedig egy „pull" fázis következik: a graph legfrissebb állapota alapján számítódnak ki a computed-ok, lentről felfelé.
A glitch-mentes update időrendje
- t = 0ms —
firstName.set('Zoli'): firstName új verzió. fullName & initials „dirty"-re jelölve. - t = 0ms —
lastName.set('Kovacs'): lastName új verzió. Ugyanazok a computed-ok már dirty-k, nincs duplikáció. - t = 1ms — Microtask lefut: az effect a scheduler-ben sorban áll, most meg fog hívódni.
- t = 1ms — Pull: az effect olvassa
fullName-et — computed újraszámol — „Zoli Kovacs". Majdinitials— „ZK". - t = 1ms — Effect body:
console.log('Zoli Kovacs', 'ZK'). Egyszer. Konzisztens állapottal.
A kulcs: az effect egyszer fut le két signal-írás után, és egy konzisztens snapshot-ot kap. Soha nem látja a „Zoli Soft" közbülső állapotot.
A computed szinkron, az effect aszinkron
Egy fontos finomság: a
computed() olvasás minden esetben szinkron. Ha olvasod egy computed-ot közvetlenül a signal írása után, azonnal az új értéket kapod. A batch-elés csak az effect-re és a template-renderelésre vonatkozik. Ez azért fontos, mert a template-ben olvasott signal konzisztens pillanatképet ad egy-egy renderelésnél.Mit tanulsz
- signalsKözéphaladó
09 — Gyakorlati minta
Egy tipikus komponens felépítése
Nem elmélet, hanem egy éles, signal-alapú komponens, amit bátran vihetsz a saját projektedbe.
Lássuk, hogyan néz ki egy teljes, signal-alapú komponens, ami a fentieket használja. A példa: egy egyszerű termékszűrő, ami kategória és max-ár alapján szűr, számolja a találatok számát, és logolja a felhasználó interakcióit.
product-filter.component.tstypescript
import { Component, ChangeDetectionStrategy, signal, computed, effect } from '@angular/core';
interface Product {
id: string;
name: string;
category: string;
price: number;
}
@Component({
selector: 'app-product-filter',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<select [value]="category()" (change)="onCategoryChange($event)">
<option value="all">All</option>
<option value="fruit">Fruit</option>
</select>
<input type="number" [value]="maxPrice()"
(input)="onMaxPriceChange($event)" />
<p>Results: {{ resultCount() }}</p>
@for (p of filtered(); track p.id) {
<div>{{ p.name }} — {{ p.price }} USD</div>
}
`
})
export class ProductFilterComponent {
readonly products = signal<Product[]>([]);
readonly category = signal('all');
readonly maxPrice = signal(1000);
readonly filtered = computed(() => {
const cat = this.category();
const max = this.maxPrice();
return this.products().filter(p =>
(cat === 'all' || p.category === cat) && p.price <= max
);
});
readonly resultCount = computed(() => this.filtered().length);
constructor() {
// Analytics effect — only reacts to filter changes
effect(() => {
const cat = this.category();
const max = this.maxPrice();
analytics.track('filter-changed', { cat, max });
});
}
onCategoryChange(e: Event) {
this.category.set((e.target as HTMLSelectElement).value);
}
onMaxPriceChange(e: Event) {
this.maxPrice.set(+(e.target as HTMLInputElement).value);
}
}
Nézd meg a szerkezetet. Három signal tartja az állapotot:
products, category, maxPrice. Két computed származtat belőlük: a szűrt lista és a találatszám. Egy effect reagál a filter-változásokra az analytics felé. Semmi subscribe, semmi unsubscribe, semmi manuális CD trigger. A komponens OnPush, és a template signal-olvasások miatt pontosan akkor frissül, amikor kell.10 — Konklúzió
Amit most már másként látsz
Néhány gondolat, ami egybefoglalja a signal-alapú gondolkodást.
A signal-ok nem csak API-cserét jelentenek az RxJS helyett. A signal-ok egy új gondolkodásmódot hoznak: az állapotot nem stream-ekben kezeled, hanem érték-kontextusban. Az értékek között függőségek vannak, amiket nem te deklarálsz — automatikusan kialakulnak. A framework garantálja, hogy a konzisztencia mindig megőrződjön, és hogy csak az fusson le, aminek tényleg kell.
Három dolgot vigyél magaddal:
- Az állapotot signal-ban tartsd, a származtatottat computed-ban, a mellékhatást effect-ben. Ez a hármas majdnem minden esetre elég.
- Mindig immutable módon írj — új referencia, új objektum, új tömb. Ez az OnPush-nak is jól jön, és a referencia-alapú equality-nek is.
- Ne harcolj a rendszerrel — ne tedd signal-írást effect-be, ne tegyél side effect-et computed-ba. Ha úgy érzed, „hacket" írsz, valószínűleg rosszul van felbontva az állapotod.
A signal nem egy új eszköz az eszköztárban. A signal az új eszköztár.