JOURNAL

Angular Signals Deep Dive — linkedSignal, model(), and the effect() Rules

Post C — Beyond the basics of Angular 21 Signals. Learn linkedSignal() for writable derived state, model() for two-way bindings, the effect() golden rule, and takeUntilDestroyed() for RxJS cleanup.

angular techlead signals deepdive

Read with AI

Choose content to copy and paste into your AI assistant. Nothing is sent automatically. CMS content is converted to Markdown; original Markdown is used when available.

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 Series — Back to main series overview

Discussion

Comments are reviewed before publication. Your email is kept private.

Add a comment

Name and email are required. Do not include confidential information.

Privacy & data

Loading anti-spam verification…

← Back to allĐọc tiếng Việt