5 min czytania

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

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

W pierwszej części omówiliśmy podstawy - czym jest sygnał, jak go tworzyć, jak odczytywać wartość i jak działa computed(). Teraz idziemy głębiej. Zajmiemy się effect(), sygnałami w serwisach oraz tym, jak sygnały dogadują się z RxJS.

effect() - reagowanie na zmiany

Czasem nie chcesz tylko wyświetlać wartości w szablonie - chcesz coś zrobić, gdy wartość się zmieni. Na przykład zapisać coś do localStorage, zalogować zmianę do konsoli albo wywołać jakieś zewnętrzne API. Do tego służy effect().

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

@Component({
  selector: 'app-theme',
  template: `
    <button (click)="toggleTheme()">
      Aktualny motyw: {{ theme() }}
    </button>
  `
})
export class ThemeComponent {
  theme = signal<'light' | 'dark'>('light');

  constructor() {
    effect(() => {
      document.body.setAttribute('data-theme', this.theme());
    });
  }

  toggleTheme() {
    this.theme.update(t => t === 'light' ? 'dark' : 'light');
  }
}

effect() uruchamia się automatycznie za każdym razem, gdy zmieni się dowolny sygnał odczytywany wewnątrz niego. Nie musisz mu mówić, których sygnałów ma pilnować - sam to wykrywa podczas pierwszego uruchomienia.

Kilka rzeczy, które warto wiedzieć o effect():

Wywołuje się przynajmniej raz na starcie, żeby zarejestrować zależności. Nie możesz modyfikować sygnałów wewnątrz effect() bez dodatkowej konfiguracji - Angular wyrzuci błąd, żeby zapobiec nieskończonym pętlom. Jeśli naprawdę potrzebujesz to zrobić, musisz przekazać opcję allowSignalWrites: true, ale to raczej edge case.

effect(() => {
  console.log('Zmieniono motyw na:', this.theme());
  // this.theme.set('light'); // to wyrzuci błąd bez allowSignalWrites
});

Praktyczny przykład z localStorage:

@Component({
  selector: 'app-settings',
  template: `
    <label>
      Język:
      <select (change)="setLang($event)">
        <option value="pl">Polski</option>
        <option value="en">English</option>
      </select>
    </label>
    <p>Wybrany język: {{ lang() }}</p>
  `
})
export class SettingsComponent {
  lang = signal(localStorage.getItem('lang') ?? 'pl');

  constructor() {
    effect(() => {
      localStorage.setItem('lang', this.lang());
    });
  }

  setLang(event: Event) {
    const select = event.target as HTMLSelectElement;
    this.lang.set(select.value);
  }
}

Przy każdej zmianie lang wartość jest automatycznie zapisywana do localStorage. Przy inicjalizacji komponentu sygnał pobiera zapisaną wartość, więc ustawienie przeżywa odświeżenie strony. Całkiem wygodne.

Sygnały w serwisach

To jest według mnie najciekawsze zastosowanie sygnałów i miejsce, gdzie naprawdę zaczynają błyszczeć. Zamiast trzymać stan w komponentach i przekazywać go przez @Input()/@Output() albo przez Subject z RxJS, możesz trzymać go w serwisie jako sygnały.

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

interface Todo {
  id: number;
  text: string;
  done: boolean;
}

@Injectable({ providedIn: 'root' })
export class TodoService {
  private todos = signal<Todo[]>([]);

  // computed - tylko do odczytu, automatycznie aktualizowany
  completedCount = computed(() => 
    this.todos().filter(t => t.done).length
  );

  pendingCount = computed(() =>
    this.todos().filter(t => !t.done).length
  );

  allTodos = computed(() => this.todos());

  add(text: string) {
    this.todos.update(list => [
      ...list,
      { id: Date.now(), text, done: false }
    ]);
  }

  toggle(id: number) {
    this.todos.update(list =>
      list.map(t => t.id === id ? { ...t, done: !t.done } : t)
    );
  }

  remove(id: number) {
    this.todos.update(list => list.filter(t => t.id !== id));
  }
}

Zwróć uwagę na kilka rzeczy. Sam sygnał todos jest prywatny - komponenty nie mogą go modyfikować bezpośrednio. Na zewnątrz wystawiasz tylko computed(), czyli sygnały tylko do odczytu, oraz metody, które kontrolują, jak stan może się zmieniać. To klasyczny wzorzec enkapsulacji i tutaj działa bardzo naturalnie.

Komponent korzystający z tego serwisu wygląda tak:

@Component({
  selector: 'app-todo-list',
  template: `
    <p>Do zrobienia: {{ todoService.pendingCount() }}</p>
    <p>Zrobione: {{ todoService.completedCount() }}</p>

    <input #input placeholder="Nowe zadanie" />
    <button (click)="add(input)">Dodaj</button>

    <ul>
      @for (todo of todoService.allTodos(); track todo.id) {
        <li [class.done]="todo.done">
          <span (click)="todoService.toggle(todo.id)">{{ todo.text }}</span>
          <button (click)="todoService.remove(todo.id)">usuń</button>
        </li>
      }
    </ul>
  `
})
export class TodoListComponent {
  constructor(public todoService: TodoService) {}

  add(input: HTMLInputElement) {
    if (input.value.trim()) {
      this.todoService.add(input.value.trim());
      input.value = '';
    }
  }
}

Żadnych subskrypcji, żadnego async pipe, żadnego takeUntilDestroyed. Komponent po prostu odczytuje wartości z serwisu i Angular sam wie, kiedy odświeżyć widok.

Sygnały i RxJS - nie musisz wybierać

Sporo osób pyta, czy sygnały zastępują RxJS. Krótka odpowiedź: nie, przynajmniej nie w Angular 16 i 17. Długa odpowiedź: to zależy od przypadku, ale Angular daje Ci narzędzia, żeby używać obu.

Do konwersji między sygnałami a observablami służą dwie funkcje: toSignal() i toObservable().

toSignal() - zamienia Observable na sygnał:

import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

interface Post {
  id: number;
  title: string;
}

@Component({
  selector: 'app-posts',
  template: `
    @if (posts()) {
      <ul>
        @for (post of posts(); track post.id) {
          <li>{{ post.title }}</li>
        }
      </ul>
    } @else {
      <p>Ładowanie...</p>
    }
  `
})
export class PostsComponent {
  private http = inject(HttpClient);

  posts = toSignal(
    this.http.get<Post[]>('https://jsonplaceholder.typicode.com/posts')
  );
}

toSignal() subskrybuje się na Observable i automatycznie odsubskrybowuje, gdy komponent jest niszczony. Nie musisz pamiętać o unsubscribe() ani takeUntilDestroyed. Sygnał zwraca undefined dopóki Observable nie wyemituje pierwszej wartości - dlatego w szablonie sprawdzamy @if (posts()).

Możesz też podać wartość początkową:

posts = toSignal(
  this.http.get<Post[]>('https://jsonplaceholder.typicode.com/posts'),
  { initialValue: [] }
);

Wtedy sygnał od razu ma wartość [] zamiast undefined.

toObservable() działa w drugą stronę, zamienia sygnał na Observable:

import { signal } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';

@Component({
  selector: 'app-search',
  template: `
    <input (input)="setQuery($event)" placeholder="Szukaj..." />
    <ul>
      @for (result of results(); track result.id) {
        <li>{{ result.title }}</li>
      }
    </ul>
  `
})
export class SearchComponent {
  private http = inject(HttpClient);

  query = signal('');

  results = toSignal(
    toObservable(this.query).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(q => 
        q ? this.http.get<any[]>(`/api/search?q=${q}`) : of([])
      )
    ),
    { initialValue: [] }
  );

  setQuery(event: Event) {
    this.query.set((event.target as HTMLInputElement).value);
  }
}

To jest ciekawy wzorzec - sygnał do trzymania wartości wejściowej, RxJS do obsługi logiki (debounce, deduplikacja, przerwanie poprzedniego żądania), i z powrotem sygnał do wyświetlania wyników. Każde narzędzie robi to, do czego jest stworzone.

Kiedy używać sygnałów, a kiedy RxJS?

Sygnały dobrze sprawdzają się przy lokalnym stanie komponentu, stanie aplikacji trzymanym w serwisach, wartościach pochodnych przez computed() i prostych reakcjach na zmiany przez effect().

RxJS nadal ma sens przy złożonych strumieniach danych, operacjach asynchronicznych wymagających operatorów takich jak switchMap, mergeMap czy combineLatest, przy WebSocketach i przy kodzie, który już masz i działa.

Nie ma potrzeby przepisywać całej aplikacji na sygnały na raz. Możesz wprowadzać je stopniowo tam, gdzie mają sens.

Kilka rzeczy, na które warto uważać

Sygnały w Angular 16 to developer preview - API może się trochę zmienić w kolejnych wersjach. I rzeczywiście zmieniało się - na przykład mutate() zostało usunięte w Angular 17 na rzecz update().

effect() wywołuj tylko w kontekście iniekcji - czyli w konstruktorze komponentu lub serwisu, albo przekaż mu Injector. Wywołanie effect() poza kontekstem iniekcji skończy się błędem.

Jeśli masz sygnał przechowujący obiekt lub tablicę, Angular porównuje referencje. Jeśli zmutowałeś obiekt bez zmiany referencji, sygnał tego nie wykryje. Dlatego w update() zawsze zwracaj nowy obiekt lub nową tablicę:

// źle - zmutowałeś ten sam obiekt
this.items.update(list => {
  list.push(newItem);
  return list;
});

// dobrze - nowa tablica, nowa referencja
this.items.update(list => [...list, newItem]);

Podsumowanie

Sygnały to przemyślany krok w stronę prostszej reaktywności w Angularze. Nie musisz zarządzać subskrypcjami, nie musisz myśleć o tym, kiedy odsubskrybować, a Angular może precyzyjniej wiedzieć, co odświeżyć w widoku.

Nie zastępują RxJS w każdym przypadku i nie trzeba od razu przepisywać całego projektu. Ale jeśli zaczynasz nowy komponent albo nowy serwis - sygnały są prawdopodobnie najprostszym sposobem na zarządzanie stanem.