You’ve learned signal(), computed(), and effect(). You’re using them correctly for basic state. But the sharp edges — linkedSignal(), the effect() mutation trap, model() for two-way binding — are the difference between an Angular developer and an Angular 21 native.

This post covers the advanced Signals primitives that experienced .NET developers and Angular developers miss.

linkedSignal() — When computed() Isn’t Enough

computed() creates a signal that derives its value from other signals. It’s read-only — you can never call .set() on it.

linkedSignal() creates a signal that derives its default value from other signals, but can also be written to directly.

The ecommerce use case: selected size/color variant

@Injectable({ providedIn: 'root' })
export class ProductVariantStore {
  product = signal<Product | null>(null);

  // ❌ computed() — cannot be overridden by user interaction
  // selectedVariant = computed(() => this.product()?.variants[0] ?? null);

  // ✅ linkedSignal() — defaults to first variant, but user can pick another
  selectedVariant = linkedSignal(() => this.product()?.variants[0] ?? null);

  selectVariant(variant: Variant) {
    this.selectedVariant.set(variant); // user choice overrides the default
  }

  loadProduct(product: Product) {
    this.product.set(product);
    // selectedVariant automatically resets to variants[0] because
    // the linked computation re-runs when product() changes
  }
}

Why this matters: Without linkedSignal(), you’d need a separate boolean isUserSelected flag to track whether to use the derived value or the user override. linkedSignal() removes that complexity.

Typed form with linkedSignal:

export class CheckoutComponent {
  private cart = inject(CartService);

  // Shipping country defaults to 'SA' from user profile, but is editable
  shippingCountry = linkedSignal(() =>
    inject(ProfileService).profile()?.defaultCountry ?? 'SA'
  );

  // Computed shipping cost reacts to country changes
  shippingCost = computed(() =>
    SHIPPING_RATES[this.shippingCountry()] ?? 25
  );
}

model() — Two-Way Bindable Signal Inputs

model() creates a signal that functions as both an input AND an output — enabling two-way binding from parent components.

// QuantitySelectorComponent
@Component({
  selector: 'app-qty-selector',
  template: `
    <button (click)="decrement()">−</button>
    <span>{{ quantity() }}</span>
    <button (click)="increment()">+</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuantitySelectorComponent {
  quantity = model(1);  // Default value is 1
  max = input(99);

  increment() {
    if (this.quantity() < this.max()) {
      this.quantity.update(q => q + 1);
    }
  }
  decrement() {
    if (this.quantity() > 1) this.quantity.update(q => q - 1);
  }
}

Parent usage with two-way binding:

<!-- CartItemComponent -->
<app-qty-selector [(quantity)]="item.qty" [max]="item.maxQty" />

Under the hood, [(quantity)] desugars into:

<app-qty-selector [quantity]="item.qty" (quantityChange)="item.qty = $event" />

model() generates both the quantity input and quantityChange output automatically.

The effect() Golden Rule

effect() is the most misused Signal primitive. The rule is simple and absolute:

effect() is for side effects to external systems only. Never use effect() to update other signals.

❌ The mutation trap

@Component({...})
export class ProductListComponent {
  filter = signal<FilterState>({ category: null, page: 1 });
  pageTitle = signal('All Products');

  constructor() {
    // ❌ WRONG — writing a signal inside effect() causes:
    // 1. Potential infinite loops
    // 2. Unpredictable update order
    // 3. Angular dev mode throws ExpressionChangedAfterItHasBeenChecked
    effect(() => {
      const category = this.filter().category;
      this.pageTitle.set(category ? `Category: ${category}` : 'All Products'); // ❌
    });
  }
}

✅ Use computed() for derived signal values

@Component({...})
export class ProductListComponent {
  filter = signal<FilterState>({ category: null, page: 1 });

  // ✅ Derived value — always consistent, no loops
  pageTitle = computed(() =>
    this.filter().category
      ? `Category: ${this.filter().category}`
      : 'All Products'
  );
}

effect() for legitimate side effects only

constructor() {
  // ✅ OK: writing to localStorage (external system)
  effect(() => {
    localStorage.setItem('cart', JSON.stringify(this.cart.items()));
  });

  // ✅ OK: sending analytics event (external system)
  effect(() => {
    const product = this.selectedProduct();
    if (product) this.analytics.trackProductView(product.id);
  });

  // ✅ OK: updating document title (DOM, external to Angular)
  effect(() => {
    document.title = `${this.product()?.name ?? 'Products'} | TechShop`;
  });
}

toSignal() — Bridging RxJS to Signals

When you have an RxJS Observable (e.g., from SignalR, WebSocket, or older services), toSignal() converts it to a readable Signal.

@Injectable({ providedIn: 'root' })
export class InventoryRealtimeService {
  private hub = new signalR.HubConnectionBuilder()
    .withUrl('/hubs/inventory')
    .build();

  private stockUpdates$ = new Subject<StockUpdate>();

  // Signal auto-subscribes and tracks current value
  latestUpdate = toSignal(this.stockUpdates$.asObservable(), {
    initialValue: null
  });

  constructor() {
    this.hub.on('InventoryUpdated', (update: StockUpdate) => {
      this.stockUpdates$.next(update);
    });
    this.hub.start();
  }
}

Component usage — no subscription management needed:

export class ProductDetailComponent {
  private inventory = inject(InventoryRealtimeService);
  product = input.required<Product>();

  // Reacts to real-time stock updates without subscribe()
  currentStock = computed(() => {
    const update = this.inventory.latestUpdate();
    return update?.productId === this.product().id
      ? update.stockQty
      : this.product().stockQty;
  });
}

takeUntilDestroyed() — Clean RxJS Subscriptions

When you must use subscribe() (e.g., for side effects that toSignal() can’t handle), use takeUntilDestroyed() instead of complex ngOnDestroy teardown.

@Component({...})
export class OrderTrackingComponent {
  private destroyRef = inject(DestroyRef);
  private orderStatus = signal<OrderStatus | null>(null);

  ngOnInit() {
    // ✅ Automatically unsubscribes when component is destroyed
    this.orderService.statusUpdates$.pipe(
      filter(update => update.orderId === this.orderId()),
      takeUntilDestroyed(this.destroyRef)  // no ngOnDestroy needed
    ).subscribe(update => {
      this.orderStatus.set(update.status);
    });
  }
}

Can also be used outside ngOnInit (injection context):

constructor() {
  // Works directly in constructor — injection context is active
  this.orderService.statusUpdates$.pipe(
    takeUntilDestroyed()  // no destroyRef needed in injection context
  ).subscribe(update => this.orderStatus.set(update.status));
}

Signal Debugging

In development, use effect() for debugging:

// Temporary debug — traces all Signal reads
effect(() => {
  console.log('Filter changed:', this.filter());
  console.log('Products:', this.products());
  console.log('Loading:', this.isLoading());
});

Angular DevTools (Chrome extension) also shows Signal dependency graphs — which signals depend on which, and how often they fire.


References


Part of the Angular Tech Lead SeriesBack to main series overview

Export for reading

Comments