← Vissza a cikkekhez
technologytutorial

Signals mélyen — signal, computed, effect, és a reactive graph

Zsaga
#featured
Signals mélyen — a reactive graph: signal, computed és effect csomópontok automatikus függőségekkel
A reactive graph: minden signal tudja, kik olvassák, és minden computed tudja, miből származtatta az értékét
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:
  1. Producer (termelő) — az az objektum, ami értéket szolgáltat. Egy signal() például producer.
  2. Consumer (fogyasztó) — aki olvassa a producer értékét. Egy computed() vagy egy effect() fogyasztó (is).
  3. 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:
  1. 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.
  2. 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).
  3. 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 vagy inject()-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 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 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

  1. t = 0msfirstName.set('Zoli'): firstName új verzió. fullName & initials „dirty"-re jelölve.
  2. t = 0mslastName.set('Kovacs'): lastName új verzió. Ugyanazok a computed-ok már dirty-k, nincs duplikáció.
  3. t = 1ms — Microtask lefut: az effect a scheduler-ben sorban áll, most meg fog hívódni.
  4. t = 1ms — Pull: az effect olvassa fullName-et — computed újraszámol — „Zoli Kovacs". Majd initials — „ZK".
  5. 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:
  1. 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.
  2. 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.
  3. 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.

Vissza az elejére