Back to list
September 14, 2026•11 min read

Angular Signals: why effect() is a bad state synchronizer

Copying signal into signal inside effect() is a classic footgun. Use computed() for derived state; linkedSignal() when it must stay writable.

AngularTypeScriptFrontend

You have an items signal and a separate selectedId. When the list changes, you want to clear the selection, so you add an effect that calls selectedId.set(null). It works. Until Angular throws ExpressionChangedAfterItHasBeenChecked, or the effect and the state start chasing each other.

Angular's docs are explicit: effect is a last resort, and copying data from one signal into another means your source of truth should live higher up, with derived state modeled through computed() or linkedSignal(). Official guide: Effects - when to use them and what to avoid.

The problem: syncing state with effect

Hypothetical example: a product picker. The list comes from outside; the user picks one 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() {
    // Anti-pattern: effect syncs 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);
  }
}

At a glance it looks fine: react to list changes, reset a stale selection. The cost shows up later.

Effects run asynchronously during change detection. Writing signal state inside an effect is state propagation, which the docs tell you to avoid: ExpressionChangedAfterItHasBeenChecked, circular updates, and extra CD cycles. Effects track reads dynamically; it is easy to pull in a dependency you did not mean to track, or to build a set → re-run → set loop.

Same smell in a different shape: an effect that copies a derived flag into another writable signal (isValid, a label, a UI boolean). That is still signal → signal copying. You end up with two places that claim to own the same fact.

Read-only: use computed() instead of copying

When you only need a read-only value derived from other signals, use computed(). Overview: 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 and isSelectionValid are not separate stores. They are views over existing signals: lazy, memoized, no set inside an effect. If selectedId points at an id that left the list, selectedProduct simply returns null. You do not need a second signal to "catch up."

Trade-off: computed is read-only. You cannot set it from the UI. If the user must pick a value and that value should reset or recalculate when the source changes, that is linkedSignal.

Writable linked state: linkedSignal()

linkedSignal is a writable signal tied to other state. You pass a computation (like computed); when the computation result changes, the linked signal updates. You can still .set() / .update() from the UI. Official write-up with the shipping method picker: Dependent state with linkedSignal.

Hypothetical example (docs pattern, trimmed to an options list):

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" },
  ]);

  // Default: first option; resets when the list changes
  readonly selectedOption = linkedSignal(() => this.shippingOptions()[0]);

  changeShipping(index: number): void {
    this.selectedOption.set(this.shippingOptions()[index]);
  }
}

When shippingOptions changes, selectedOption becomes the computation result (here: the first item). No effect. No manual set(null).

Often you want to keep the selection if that id still exists in the new list. Use source + computation with the previous value (same idea as the official shipping example):

readonly selectedOption = linkedSignal({
source: this.shippingOptions,
  computation: (newOptions, previous) => {
    return (
      newOptions.find((opt) => opt.id === previous?.value.id) ?? newOptions[0]
    );
  },
});

Still one state model: the user can override the choice, and when the source changes Angular recomputes the linked signal declaratively. No second effect chasing selectedId.

When effect is the right tool

Effect fits when you leave the signal world: imperative, non-signal APIs. The docs call out logging / analytics, syncing to localStorage / session storage / cookies, custom DOM you cannot express in the template, and canvas / chart libraries / other third-party UI.

Example (hypothetical): persist a preference to 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});
    });
  }
}

You are not copying signal into signal. You are syncing a signal with the outside world. That is the job effect is for.

Quick comparison

Read-only state from other signals: computed(). Writable state linked to another signal: linkedSignal(). Sync to localStorage, logging, DOM, or a chart: effect() (or afterRenderEffect after DOM commit). effect plus set on another signal: avoid; that is state propagation.

Old RxJS habits (tap plus a store side effect) do not map one-to-one. With Signals, Angular wants derived state to stay declarative and keeps effect at the boundary with imperative APIs.

Takeaway

If an effect reads one signal and .set()s another, stop. Check whether computed is enough. If the UI must write that value and a source (list, input, another signal) should reset or correct it, use linkedSignal. Leave effect for logging, storage, and third-party DOM.

Fewer hidden change-detection cycles. One source of truth. And fewer nights spent on ExpressionChangedAfterItHasBeenChecked.