Wróć do listy
14 września 2026•11 min czytania

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

AngularTypeScriptFrontend

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.

import { Component, effect, signal } from "@angular/core";
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.

import { Component, computed, signal } from "@angular/core";
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):

import { Component, linkedSignal, signal } from "@angular/core";
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):

readonly selectedOption = linkedSignal({
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.

import { Component, effect, signal } from "@angular/core";
@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.