effect i sprzątanie: DestroyRef
computed liczy wartości do pokazania. Czasem jednak przy zmianie danych trzeba coś zrobić poza szablonem: zmienić tytuł karty przeglądarki, zapisać notatkę w pamięci przeglądarki. Do tego służy effect. A komponent, który uruchomił stoper, musi go zatrzymać, gdy znika — tu pomoże DestroyRef.
Czego się dziś nauczysz
- Uruchomisz kod przy każdej zmianie sygnału za pomocą
effect. - Wybierzesz między
computed(wartość) aeffect(czynność). - Zapiszesz dane w
localStorage, żeby przetrwały odświeżenie strony. - Zobaczysz, że
setIntervaldziała dalej po usunięciu komponentu. - Zatrzymasz stoper w chwili usunięcia komponentu przez
DestroyRef.onDestroy.
Przygotowanie: lekcje 09–11 (sygnały, setInterval, computed), lekcja 12 (@if), lekcja 29 (komponent dziecko), lekcja 36 (constructor). Przewidywany czas: 45 min z zadaniami.
Reagować, a nie liczyć
computed odpowiada na pytanie „ile?”: kwota, liczba wolnych miejsc, czy formularz jest pełny. Nie powinien niczego zmieniać poza swoim wynikiem. Ale czasem przy zmianie danych trzeba wykonać czynność poza szablonem: zmienić document.title, zapisać coś w pamięci przeglądarki, wypisać informację w konsoli. Takie czynności nazywa się efektami ubocznymi.
| Narzędzie | Zapis | Kiedy |
|---|---|---|
computed | readonly razem = computed(() => this.a() * 15); | potrzebujesz wartości do pokazania |
effect | effect(() => { document.title = …this.a()…; }); | potrzebujesz coś zrobić przy każdej zmianie |
DestroyRef | inject(DestroyRef).onDestroy(() => { … }); | trzeba posprzątać, gdy komponent znika |
Funkcja w effect wykona się raz na starcie, a potem za każdym razem, gdy zmieni się któryś z sygnałów w niej odczytanych — tak samo jak computed, ale niczego nie zwraca. effect tworzysz w konstruktorze albo przy deklaracji pola, tak jak inject.
Sprzątanie
Komponent, który uruchomił setInterval, musi go zatrzymać, gdy znika z ekranu (@if stał się fałszywy, router przeszedł na inną stronę). JavaScript nie zrobi tego sam — stoper będzie działał dalej, choć nie ma już czego wyświetlać. Funkcja przekazana do onDestroy wywoła się w chwili usunięcia komponentu.
Tytuł karty przeglądarki z effect
Koszyk z licznikiem. effect przy każdej zmianie licznika ustawia tytuł karty przeglądarki i wypisuje informację w konsoli.
import { Component, effect, signal } from '@angular/core';
@Component({
selector: 'app-demo',
imports: [],
templateUrl: './demo.html'
})
export class Demo {
readonly wKoszyku = signal(0);
constructor() {
effect(() => {
const n = this.wKoszyku();
document.title = n === 0 ? 'Sklepik ZSMI' : '(' + n + ') Sklepik ZSMI';
console.log('effect: w koszyku jest', n);
});
}
dodaj(): void {
this.wKoszyku.update(n => n + 1);
}
}
<div class="p-3">
<p class="fs-5">W koszyku: {{ wKoszyku() }}</p>
<button class="btn btn-primary me-2" (click)="dodaj()">Dodaj</button>
<button class="btn btn-outline-secondary" (click)="wKoszyku.set(0)">Opróżnij</button>
</div>
Karta przeglądarki: (2) Sklepik ZSMI
Konsola:
effect: w koszyku jest 0
effect: w koszyku jest 1
effect: w koszyku jest 2
Pierwszy wpis w konsoli pojawia się od razu po otwarciu strony. Kliknij „Opróżnij”, gdy koszyk już jest pusty — nowego wpisu nie ma: sygnał dostał tę samą wartość, więc nic się nie zmieniło i effect się nie uruchomił. Tak sklepy i poczta pokazują w tytule liczbę nowych rzeczy.
Zatrzymaj się
Tytuł karty jest poza szablonem komponentu. Dlaczego nie da się go ustawić przez computed ani interpolację {{ }}?
Notatka, która przetrwa odświeżenie
localStorage to mała pamięć przeglądarki, osobna dla każdej strony. Notatka jest zapisywana przez effect przy każdej zmianie, a przy starcie komponentu — odczytywana jako wartość początkowa sygnału.
import { Component, effect, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-demo',
imports: [FormsModule],
templateUrl: './demo.html'
})
export class Demo {
readonly klucz = 'notatka-klubu';
readonly notatka = signal(localStorage.getItem(this.klucz) ?? '');
constructor() {
effect(() => {
localStorage.setItem(this.klucz, this.notatka());
});
}
wyczysc(): void {
this.notatka.set('');
}
}
<div class="p-3">
<div style="max-width: 30rem">
<label class="form-label" for="notatka">Notatki przed turniejem</label>
<textarea id="notatka" name="notatka" class="form-control mb-2" rows="5" [(ngModel)]="notatka"></textarea>
<button class="btn btn-sm btn-outline-danger" (click)="wyczysc()">Wyczyść</button>
<p class="small text-secondary mt-2">Znaków zapisanych w przeglądarce: {{ notatka().length }}</p>
</div>
</div>
Notatki przed turniejem
┌──────────────────────────────┐
│ Zabrać pady i przedłużacz. │ ← tekst nadal jest
└──────────────────────────────┘
Znaków zapisanych w przeglądarce: 26
localStorage.getItem(klucz) zwraca zapisany tekst albo null, gdy nic jeszcze nie zapisano — stąd ?? ''. Podgląd zapisu: F12 → Application → Local Storage → wpis notatka-klubu, który zmienia się przy każdym znaku.
Tylko tekst, tylko ta przeglądarka
localStorage przechowuje wyłącznie tekst i tylko w tej przeglądarce na tym komputerze. Nie nadaje się do haseł ani danych, które mają zobaczyć inni — to robi serwer. Tablicę albo obiekt zamieniasz na tekst przez JSON.stringify(…), a z powrotem — przez JSON.parse(…).
Zatrzymaj się
Zamknij kartę i otwórz przykład ponownie. Notatka nadal jest? A w trybie incognito?
Problem: stoper, który nie umiera
Komponent app-stoper uruchamia setInterval przy tworzeniu i wypisuje każde tyknięcie w konsoli. Rodzic pokazuje go i chowa przyciskiem przez @if, więc stoper jest naprawdę tworzony i usuwany. Otwórz konsolę i patrz uważnie.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-stoper',
imports: [],
template: `<div class="alert alert-info mb-0">Stoper: {{ sekundy() }} s</div>`
})
export class Stoper {
readonly sekundy = signal(0);
constructor() {
setInterval(() => {
this.sekundy.update(s => s + 1);
console.log('tyk', this.sekundy());
}, 1000);
}
}
import { Component, signal } from '@angular/core';
import { Stoper } from './stoper';
@Component({
selector: 'app-demo',
imports: [Stoper],
templateUrl: './demo.html'
})
export class Demo {
readonly widoczny = signal(true);
}
<div class="p-3">
<div style="max-width: 24rem">
<button class="btn btn-primary mb-3" (click)="widoczny.set(!widoczny())">
{{ widoczny() ? 'Ukryj stoper' : 'Pokaż stoper' }}
</button>
@if (widoczny()) {
<app-stoper />
}
</div>
</div>
tyk 1
tyk 2
tyk 3 ← tu kliknięto „Ukryj”
tyk 4
tyk 5 ← stopera nie ma na ekranie, a nadal tyka
Pokaż i ukryj stoper jeszcze dwa razy — w konsoli tykają już trzy niezależne liczniki naraz, a żaden nie jest widoczny. W dużej aplikacji z setkami przejść takie „zapomniane” stopery spowalniają stronę i zużywają baterię.
Zatrzymaj się
Przejdź w laboratorium na inny przykład. Czy tyknięcia w konsoli ustały?
Rozwiązanie: DestroyRef.onDestroy
Ten sam stoper, ale zapamiętuje numer zwrócony przez setInterval i rejestruje sprzątanie. Gdy @if usunie komponent, Angular wywoła funkcję z onDestroy, a ta zatrzyma stoper przez clearInterval(numer).
import { Component, DestroyRef, inject, signal } from '@angular/core';
@Component({
selector: 'app-stoper',
imports: [],
template: `<div class="alert alert-success mb-0">Stoper: {{ sekundy() }} s</div>`
})
export class Stoper {
readonly sekundy = signal(0);
constructor() {
const numer = setInterval(() => {
this.sekundy.update(s => s + 1);
console.log('tyk', this.sekundy());
}, 1000);
inject(DestroyRef).onDestroy(() => {
clearInterval(numer);
console.log('stoper zatrzymany po', this.sekundy(), 's');
});
}
}
import { Component, signal } from '@angular/core';
import { Stoper } from './stoper';
@Component({
selector: 'app-demo',
imports: [Stoper],
templateUrl: './demo.html'
})
export class Demo {
readonly widoczny = signal(true);
readonly ilePokazan = signal(1);
przelacz(): void {
if (!this.widoczny()) {
this.ilePokazan.update(n => n + 1);
}
this.widoczny.set(!this.widoczny());
}
}
<div class="p-3">
<div style="max-width: 24rem">
<button class="btn btn-primary mb-3" (click)="przelacz()">
{{ widoczny() ? 'Ukryj stoper' : 'Pokaż stoper' }}
</button>
@if (widoczny()) {
<app-stoper />
}
<p class="small text-secondary mt-2">Stoper tworzony już {{ ilePokazan() }} raz(y).</p>
</div>
</div>
tyk 1
tyk 2
stoper zatrzymany po 2 s
(cisza)
Pokaż stoper znowu — liczy od zera, bo to nowy komponent. Niezależnie od liczby pokazań w konsoli tyka najwyżej jeden licznik. W starszym kodzie zobaczysz zamiast tego metodę ngOnDestroy() w klasie komponentu (poznasz ją w lekcji 38); robi to samo, ale DestroyRef pozwala zapisać sprzątanie tuż obok kodu, który go wymaga.
Zatrzymaj się
numer to zwykła stała w konstruktorze, a nie pole klasy. Skąd funkcja w onDestroy, wywołana dużo później, wie, jaką ma wartość?
Zestawienie elementów
| Element | Znaczenie |
|---|---|
effect(() => { … }) z @angular/core | czynność przy starcie i przy każdej zmianie odczytanych sygnałów |
document.title = '…' | tytuł karty przeglądarki |
localStorage.setItem(klucz, tekst) | zapis tekstu w pamięci przeglądarki |
localStorage.getItem(klucz) | odczyt; null, gdy brak wpisu |
JSON.stringify(obiekt) / JSON.parse(tekst) | obiekt ↔ tekst |
const numer = setInterval(f, 1000) | uruchomienie stopera; zwraca numer |
clearInterval(numer) | zatrzymanie stopera o tym numerze |
inject(DestroyRef).onDestroy(() => { … }) | funkcja wywoływana przy usunięciu komponentu |
Zanim utkniesz
| Zapis | Problem |
|---|---|
effect utworzony w metodzie wywoływanej po kliknięciu | W konsoli: NG0203: effect() can only be used within an injection context …. Twórz go w konstruktorze albo przy deklaracji pola. |
effect ustawiający inny sygnał, np. this.razem.set(this.a() * 15) | Działa, ale to zadanie dla computed — prostszego i bez dodatkowego kroku odświeżania. |
effect, który nie czyta żadnego sygnału | Wykona się tylko raz, na starcie. Effect reaguje wyłącznie na sygnały odczytane w środku. |
localStorage.setItem('lista', this.lista()) z tablicą | Zapisuje się tekst Ala,Bartek, który trudno odtworzyć. Tablice i obiekty zapisuj przez JSON.stringify. |
setInterval bez clearInterval w komponencie, który może zniknąć | Stoper działa dalej po usunięciu komponentu, a każde ponowne pokazanie dokłada kolejny. |
clearInterval(this.sekundy()) | Nic się nie zatrzymuje: clearInterval potrzebuje numeru zwróconego przez setInterval, a nie liczby sekund. |
Zadania
Lista zadań z polem i przyciskiem „Dodaj”. Tytuł karty przeglądarki pokazuje liczbę zadań: „Zadania (3)”.
Kryterium sukcesu: tytuł zmienia się po każdym dodaniu i usunięciu zadania.
Podpowiedź
Przykład 1 z sygnałem-tablicą: this.zadania().length.
Pole „Twój nick” z [(ngModel)] i powitanie „Cześć, …!”. Nick ma przetrwać odświeżenie strony. Przycisk „Zapomnij mnie” czyści pole i wpis.
Kryterium sukcesu: po F5 powitanie pokazuje zapisany nick; po „Zapomnij mnie” i F5 — pole jest puste.
Podpowiedź
Przykład 2. Usunięcie wpisu: localStorage.removeItem(klucz) — albo po prostu zapis pustego tekstu przez effect.
Trzy ustawienia: tryb ciemny (checkbox), rozmiar tekstu (suwak 12–24) i imię (pole). Wszystkie mają przetrwać odświeżenie strony. Zapisz je jako jeden obiekt w localStorage.
Kryterium sukcesu: po odświeżeniu wszystkie trzy wartości są takie jak przed nim; w Application → Local Storage jest jeden wpis z tekstem JSON.
Podpowiedź
Jeden effect czyta trzy sygnały i zapisuje JSON.stringify({ ciemny: …, rozmiar: …, imie: … }). Przy starcie: JSON.parse(localStorage.getItem(klucz) ?? '{}').
Komponent app-odliczanie z wejściem sekundy odlicza w dół co sekundę i po dojściu do zera pokazuje „Przerwa!” i sam zatrzymuje stoper. Rodzic pokazuje go i chowa przyciskiem.
Kryterium sukcesu: odliczanie zatrzymuje się na zerze; schowanie komponentu w trakcie odliczania zatrzymuje stoper (wpis w konsoli).
Podpowiedź
Licz upływ czasu w sygnale minelo, a pozostały czas w computed(() => this.sekundy() - this.minelo()). Stoper zatrzymuj w dwóch miejscach: w jego funkcji, gdy zostanie 0, i w onDestroy.
W aplikacji z lekcji 34 dodaj na stronie Start zegar pokazujący aktualną godzinę z sekundami. Zegar ma się zatrzymywać, gdy przechodzisz na inną stronę — sprawdź to wpisem w konsoli.
Kryterium sukcesu: na Start zegar działa; po przejściu na inną stronę w konsoli pojawia się wpis o zatrzymaniu, a tyknięcia ustają.
Podpowiedź
Router usuwa komponent strony przy przejściu, więc DestroyRef zadziała tak samo jak przy @if. Godzina: new Date().toLocaleTimeString().
Lista zakupów: pole, przycisk „Dodaj”, przy każdej pozycji checkbox „kupione” i przycisk „Usuń”. Cała lista (tablica obiektów) ma przetrwać odświeżenie strony. W tytule karty: „Zakupy: 2 do kupienia”.
Kryterium sukcesu: po F5 lista, łącznie ze stanem checkboxów, jest taka jak przed odświeżeniem; tytuł zgadza się z liczbą niekupionych pozycji.
Podpowiedź
Dwa effecty albo jeden: zapis JSON.stringify(this.lista()) i document.title. Liczbę niekupionych policz w computed, a effect tylko ją przepisze do tytułu.
Co trzeba zapamiętać
effect(() => …)wykonuje czynność przy starcie i przy każdej zmianie odczytanych w nim sygnałów.computed— do wartości;effect— do czynności poza szablonem (tytuł strony,localStorage, konsola).effectiinjecttworzysz w konstruktorze albo przy deklaracji pola.localStorageprzechowuje tekst między odświeżeniami; obiekty — przezJSON.stringify/JSON.parse.inject(DestroyRef).onDestroy(…)sprząta po komponencie — zatrzymuje stopery, gdy komponent znika.
Sprawdź się: komponent zegara użytkownik często otwiera i zamyka. Jakie objawy zobaczysz po kwadransie, jeśli zapomnisz o sprzątaniu?
Dokumentacja: angular.dev — Effects.