Angular Signals: dlaczego effect() to zły synchronizator stanu
Kopiowanie signal do signal w effect() to klasyczny footgun. Na stan wyliczony bierz computed(); na zapisywalny powiązany: linkedSignal().
Masz listę elementów w signal i osobny selectedId. Gdy lista się zmienia, chcesz wyczyścić wybór, więc dopisujesz effect, który robi selectedId.set(null). Działa. Aż do momentu, gdy Angular zacznie narzekać na ExpressionChangedAfterItHasBeenChecked, albo gdy efekt i stan zaczną się doganiać w kółko.
Dokumentacja Angulara mówi wprost: effect to ostateczność, a kopiowanie danych z jednego signalu do drugiego to sygnał, że źródło prawdy powinno być wyżej, a stan pochodny modelujesz przez computed() albo linkedSignal(). Oficjalny przewodnik: Effects - kiedy używać i czego unikać.
Problem: sync state effectem
Poniżej hipotetyczny przykład: picker produktów. Lista przychodzi z zewnątrz; użytkownik wybiera jeden id.
interface Product {
id: string;
name: string;
}
@Component({
selector: "app-product-picker",
templateUrl: "./AppProductPickerComponent.html",
})
export class AppProductPickerComponent {
readonly products = signal<Product[]>([]);
readonly selectedId = signal<string | null>(null);
constructor() {
// Antywzorzec: effect synchronizuje signal → signal
effect(() => {
const ids = new Set(this.products().map((p) => p.id));
const current = this.selectedId();
if (current !== null && !ids.has(current)) {
this.selectedId.set(null);
}
});
}
select(id: string): void {
this.selectedId.set(id);
}
}Na pierwszy rzut oka wygląda to rozsądnie: reakcja na zmianę listy, reset nieaktualnego wyboru. Koszt pojawia się później.
Effect działa asynchronicznie w trakcie change detection. Pisanie stanu w efekcie to propagacja stanu (state propagation), której dokumentacja każe unikać: ryzyko ExpressionChangedAfterItHasBeenChecked, cyklicznych aktualizacji i zbędnych przebiegów CD. Effect śledzi odczyty dynamicznie; łatwo dociągnąć zależność, której nie chciałeś, albo napisać pętlę set → re-run → set.
Inny wariant tego samego błędu: effect, który kopiuje wynik derywacji do osobnego writable signalu (isValid, label, flaga UI). To też kopiowanie signal → signal; lepiej nie mieć drugiego „źródła prawdy”.
Readonly: computed() zamiast kopiowania
Gdy potrzebujesz tylko odczytu pochodnego od innych signalów, bierz computed(). Przegląd: Angular Signals - computed.
interface Product {
id: string;
name: string;
}
@Component({
selector: "app-product-picker",
templateUrl: "./AppProductPickerComponent.html",
})
export class AppProductPickerComponent {
readonly products = signal<Product[]>([]);
readonly selectedId = signal<string | null>(null);
readonly selectedProduct = computed(() => {
const id = this.selectedId();
if (id === null) return null;
return this.products().find((p) => p.id === id) ?? null;
});
readonly isSelectionValid = computed(() => this.selectedProduct() !== null);
select(id: string): void {
this.selectedId.set(id);
}
}selectedProduct i isSelectionValid nie są osobnymi magazynami. Są widokiem na istniejące sygnały: leniwe, memoizowane, bez set w efekcie. Jeśli selectedId wskazuje na id, którego już nie ma na liście, selectedProduct po prostu zwraca null. Nie musisz „doganiać” stanu drugim signalem.
Trade-off: computed jest tylko do odczytu. Nie ustawisz go z UI. Jeśli użytkownik ma móc wybrać wartość ręcznie i wartość ma się resetować / przeliczać, gdy zmieni się źródło, wchodzi linkedSignal.
Writable linked: linkedSignal()
linkedSignal to writable signal powiązany z innym stanem. Podajesz funkcję obliczeniową (jak w computed); gdy wynik obliczenia się zmienia, wartość linked signalu też. Jednocześnie możesz robić .set() / .update() z poziomu UI. Oficjalny opis z przykładem shipping method pickera: Dependent state with linkedSignal.
Hipotetyczny przykład (wzór z docs, uproszczony do listy opcji):
interface ShippingMethod {
id: number;
name: string;
}
@Component({
selector: "app-shipping-picker",
templateUrl: "./AppShippingPickerComponent.html",
})
export class AppShippingPickerComponent {
readonly shippingOptions = signal<ShippingMethod[]>([
{ id: 0, name: "Ground" },
{ id: 1, name: "Air" },
{ id: 2, name: "Sea" },
]);
// Domyślnie: pierwsza opcja; reset przy zmianie listy
readonly selectedOption = linkedSignal(() => this.shippingOptions()[0]);
changeShipping(index: number): void {
this.selectedOption.set(this.shippingOptions()[index]);
}
}Gdy shippingOptions się zmieni, selectedOption dostaje wynik obliczenia (tu: pierwszy element). Bez effectu, bez ręcznego set(null).
Często chcesz zachować wybór, jeśli id nadal istnieje na nowej liście. Wtedy source + computation z dostępem do poprzedniej wartości (jak w oficjalnym przykładzie shipping):
source: this.shippingOptions,
computation: (newOptions, previous) => {
return (
newOptions.find((opt) => opt.id === previous?.value.id) ?? newOptions[0]
);
},
});To nadal jeden model stanu: użytkownik może nadpisać wybór, a przy zmianie źródła Angular przelicza linked signal w sposób deklaratywny. Nie ma drugiego efektu, który „dogania” selectedId.
Kiedy effect jest OK
Effect ma sens, gdy wychodzisz poza świat signalów: imperatywne / niesygnałowe API. Dokumentacja wymienia między innymi: logowanie / analytics, synchronizację z localStorage / session storage / cookies, custom DOM którego nie da się wyrazić w template, oraz canvas / bibliotekę chartów / inne third-party UI.
Przykład (hipotetyczny): zapis preferencji do localStorage.
@Component({
selector: "app-theme-prefs",
templateUrl: "./AppThemePrefsComponent.html",
})
export class AppThemePrefsComponent {
readonly theme = signal<"light" | "dark">("light");
constructor() {
effect(() => {
const value = this.theme();
localStorage.setItem("theme", value);
console.log(theme → ${value});
});
}
}Tu nie kopiujesz signalu do signalu. Syncujesz signal ze światem zewnętrznym. To dokładnie przypadek, pod który effect jest zaprojektowany.
Porównanie w skrócie
Stan tylko do odczytu z innych signalów: computed(). Stan zapisywalny i zależny od innego signalu: linkedSignal(). Sync do localStorage, log, DOM albo chart: effect() (albo afterRenderEffect po renderze DOM). effect plus set na innym signalu: unikaj, to propagacja stanu.
Stare odruchy z RxJS (tap + side effect na store) nie mapują się 1:1. W Signals Angular chce, żeby pochodny stan był deklaratywny, a effect zostawał na granicy z imperative API.
Takeaway
Jeśli effect czyta jeden signal i robi .set() na drugim, zatrzymaj się. Najpierw sprawdź, czy wystarczy computed. Jeśli UI musi pisać tę wartość, a źródło (lista, input, inny signal) ma ją resetować lub korygować, weź linkedSignal. Effect zostaw na logging, storage i third-party DOM.
Mniej ukrytych przebiegów change detection. Jedno źródło prawdy. I mniej nocy spędzonych na ExpressionChangedAfterItHasBeenChecked.