4 min czytania

Sygnały w Angularze 16 - część 1

Sygnały w Angularze 16 - część 1

Angular 16 przyniósł sporo nowości, ale jedną z tych, o których rozmawia się najczęściej, są sygnały. Jeśli śledzisz angularową społeczność, to pewnie już widziałeś posty o signal(), computed() i effect(). W tym artykule wyjaśnię, czym w ogóle są sygnały, skąd się wzięły i jak zacząć ich używać.

Skąd wziął się ten pomysł?

Żeby zrozumieć sygnały, warto cofnąć się o krok i zastanowić, jaki problem próbują rozwiązać.

Angular od zawsze używał mechanizmu wykrywania zmian (change detection) opartego na Zone.js. W skrócie: Zone.js "łata" natywne API przeglądarki (setTimeout, fetch, eventy DOM itd.) i informuje Angulara, że coś się mogło zmienić. Angular wtedy sprawdza cały drzewo komponentów i aktualizuje widok. Działa, ale ma wady - przy dużych aplikacjach może być wolne, a samo Zone.js to dodatkowa warstwa magii, przez którą czasem trudno zrozumieć, co się dzieje.

Frameworki takie jak Solid.js czy Vue 3 od jakiegoś czasu używają innego podejścia - reaktywności opartej na sygnałach. Angular postanowił pójść w tym kierunku. Sygnały w Angularze 16 są na razie w fazie developer preview, ale już działają i można ich używać.

Czym właściwie jest sygnał?

Sygnał to po prostu opakowanie na wartość, które wie, kiedy ta wartość się zmieniła i potrafi poinformować o tym wszystkich zainteresowanych.

Brzmi banalnie, ale to właśnie o to chodzi. Zamiast liczyć na to, że Zone.js wykryje zmianę, dajesz Angularowi precyzyjną informację: "ta konkretna wartość się zmieniła, odśwież to, co jej używa".

import { signal } from '@angular/core';

const count = signal(0);

console.log(count()); // 0

Kilka rzeczy do zapamiętania od razu:

Po pierwsze, sygnał tworzy się przez wywołanie funkcji signal() z wartością początkową. Po drugie, żeby odczytać wartość sygnału, wywołujesz go jak funkcję - count(), nie count. To trochę nieoczywiste na początku, ale szybko się do tego przyzwyczajasz.

Jak zmienić wartość sygnału?

Są dwa sposoby.

Pierwszy to set() - po prostu podajesz nową wartość:

count.set(5);
console.log(count()); // 5

Drugi to update() - przydaje się wtedy, gdy nowa wartość zależy od starej:

count.update(value => value + 1);
console.log(count()); // 6

Jeśli sygnał przechowuje obiekt, możesz też użyć mutate() (dostępne w Angular 16, w późniejszych wersjach zastąpione przez update()):

const user = signal({ name: 'Jan', age: 25 });

user.mutate(u => {
  u.age = 26;
});

Sygnały w komponencie - prosty przykład

Zobaczmy, jak to wygląda w praktyce. Klasyczny przykład licznika:

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <p>Wartość: {{ count() }}</p>
    <button (click)="increment()">+</button>
    <button (click)="decrement()">-</button>
    <button (click)="reset()">Reset</button>
  `
})
export class CounterComponent {
  count = signal(0);

  increment() {
    this.count.update(v => v + 1);
  }

  decrement() {
    this.count.update(v => v - 1);
  }

  reset() {
    this.count.set(0);
  }
}

W szablonie odczytujesz wartość tak samo jak w kodzie TypeScript - count(). Angular wie, że ten szablon zależy od sygnału count, i odświeży go automatycznie przy każdej zmianie. Bez Zone.js, bez ręcznych subskrypcji, bez ChangeDetectorRef.

Sygnały pochodne - computed()

Często chcesz mieć wartość, która jest wyliczana na podstawie innej wartości. Do tego służy computed():

import { signal, computed } from '@angular/core';

const price = signal(100);
const quantity = signal(3);

const total = computed(() => price() * quantity());

console.log(total()); // 300

price.set(150);
console.log(total()); // 450

computed() tworzy sygnał tylko do odczytu - nie możesz wywołać na nim set() ani update(). Jego wartość jest przeliczana automatycznie, gdy zmieni się którykolwiek z sygnałów, od których zależy. Co ważne, Angular jest sprytny i nie przelicza wartości, jeśli nikt jej w danym momencie nie potrzebuje - to tzw. lazy evaluation.

Przykład w komponencie:

@Component({
  selector: 'app-cart',
  template: `
    <p>Cena: {{ price() }} zł</p>
    <p>Ilość: {{ quantity() }}</p>
    <p>Razem: {{ total() }} zł</p>
    <button (click)="addOne()">Dodaj sztukę</button>
  `
})
export class CartComponent {
  price = signal(49);
  quantity = signal(1);
  total = computed(() => this.price() * this.quantity());

  addOne() {
    this.quantity.update(q => q + 1);
  }
}

Czym sygnały NIE są

Warto to powiedzieć wprost, bo łatwo się pomylić.

Sygnały to nie to samo co EventEmitter. EventEmitter w Angularze służy do komunikacji między rodzicem a dzieckiem przez @Output() - to zupełnie inny mechanizm.

Sygnały to nie to samo co Observable z RxJS. Obserwable są strumieniami zdarzeń, które rozwijają się w czasie. Sygnał to zawsze ma aktualną wartość, którą możesz odczytać w dowolnym momencie.

Sygnały to nie zastępstwo dla serwisów. Serwisy nadal mają sens, a sygnały w serwisach działają świetnie - ale o tym w drugiej części.

Dlaczego to ma sens?

Porównaj dwa podejścia. Stare, z propertyami i change detection:

export class OldComponent {
  count = 0;

  increment() {
    this.count++;
    // Angular musi sprawdzić cały komponent żeby wiedzieć, że coś się zmieniło
  }
}

Nowe, z sygnałami:

export class NewComponent {
  count = signal(0);

  increment() {
    this.count.update(v => v + 1);
    // Angular wie dokładnie, że `count` się zmieniło i co trzeba odświeżyć
  }
}

Przy większych aplikacjach ta różnica robi się odczuwalna - Angular może odświeżać tylko te fragmenty widoku, które faktycznie zależą od zmienionego sygnału, zamiast sprawdzać wszystko.

Kontynuacja w części 2

W części drugiej zajmiemy się effect(), czyli reakcjami na zmiany sygnałów, użyciem sygnałów w serwisach oraz tym, jak sygnały współpracują z RxJS - bo te dwa światy wcale nie muszą ze sobą walczyć.