Przejdź do treści
School IT / Przestrzeń do nauki

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.

Angular 22 effect document.title localStorage setInterval DestroyRef.onDestroy 45 min
CEL LEKCJI

Czego się dziś nauczysz

  • Uruchomisz kod przy każdej zmianie sygnału za pomocą effect.
  • Wybierzesz między computed (wartość) a effect (czynność).
  • Zapiszesz dane w localStorage, żeby przetrwały odświeżenie strony.
  • Zobaczysz, że setInterval dział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.

TEORIA

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ędzieZapisKiedy
computedreadonly razem = computed(() => this.a() * 15);potrzebujesz wartości do pokazania
effecteffect(() => { document.title = …this.a()…; });potrzebujesz coś zrobić przy każdej zmianie
DestroyRefinject(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.

PRZYKŁAD 1

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.

src/app/nowa-37-1/demo.ts — pełny plik
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);
    }
}
src/app/nowa-37-1/demo.html — pełny plik
<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>
tytuł karty i konsola po dwóch kliknięciach „Dodaj”
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ę {{ }}?

PRZYKŁAD 2

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.

src/app/nowa-37-2/demo.ts — pełny plik
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('');
    }
}
src/app/nowa-37-2/demo.html — pełny plik
<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>
po wpisaniu tekstu i F5
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?

PRZYKŁAD 3

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.

src/app/nowa-37-3/stoper.ts — pełny plik
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);
    }
}
src/app/nowa-37-3/demo.ts — pełny plik
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);
}
src/app/nowa-37-3/demo.html — pełny plik
<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>
konsola po „Ukryj stoper”
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?

PRZYKŁAD 4

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).

src/app/nowa-37-4/stoper.ts — pełny plik
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');
        });
    }
}
src/app/nowa-37-4/demo.ts — pełny plik
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());
    }
}
src/app/nowa-37-4/demo.html — pełny plik
<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>
konsola po „Ukryj stoper”
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ść?

ELEMENTY WBUDOWANE

Zestawienie elementów

ElementZnaczenie
effect(() => { … }) z @angular/coreczynność 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
CZĘSTE BŁĘDY

Zanim utkniesz

ZapisProblem
effect utworzony w metodzie wywoływanej po kliknięciuW 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łuWykona 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

Zadania

ZAD 1Licznik w tytule★☆☆

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.

ZAD 2Zapamiętany nick★☆☆

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.

ZAD 3Zapamiętane ustawienia★★☆

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) ?? '{}').

ZAD 4Odliczanie do przerwy★★☆

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.

ZAD 5Zegar na stronie z routerem★★★

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().

ZAD 6Lista zakupów na stałe★★★

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.

PODSUMOWANIE

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).
  • effect i inject tworzysz w konstruktorze albo przy deklaracji pola.
  • localStorage przechowuje tekst między odświeżeniami; obiekty — przez JSON.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.

Postęp zapisuje się w tej przeglądarce.