Effect Scheduling & Execution (v20-21)

📚 Effect Scheduling

Angular 20-21 gives you more control over when effects execute:

  • Default: Effects run during change detection
  • forceRoot: Effect runs in root injector context
  • allowSignalWrites: Allow setting signals in effects
  • manualCleanup: Control cleanup timing
  • Zoneless aware: Works correctly without Zone.js

🎯 Interview Questions

  • Q1: When do effects run by default?
  • A: During change detection, after the component's view is checked. They're batched for efficiency.
  • Q2: Why is allowSignalWrites not recommended?
  • A: Writing signals in effects can create infinite loops and makes state flow hard to reason about. Use computed() instead.
  • Q3: How do you clean up an effect early?
  • A: Call the EffectRef.destroy() method, or return a cleanup function from the effect callback.
  • Q4: What is effect() onCleanup callback for?
  • A: For cleanup when effect re-runs or is destroyed. Useful for canceling subscriptions, timers, or pending operations.

🔧 Effect Demo

Counter with Effect Logging

0

Effect Log (last 5):

[12:07:43 PM] Counter changed to: 0

Effect with Cleanup

Timer: 0

Effect starts/cleans up interval based on timerActive signal

📋 Effect Options

effect(fn, { injector })

Specify custom injector context for the effect

effect(fn, { manualCleanup: true })

Effect won't auto-destroy with component

effect(fn, { allowSignalWrites: true })

Allow setting signals inside effect (use sparingly!)

effect((onCleanup) => { onCleanup(() => ...) })

Register cleanup callback

💻 Effect Scheduling Code


import { effect, signal, untracked } from '@angular/core';

@Component({...})
export class EffectsComponent {
  counter = signal(0);
  name = signal('Angular');
  
  // ===== Basic Effect =====
  
  constructor() {
    effect(() => {
      // Runs whenever counter changes
      console.log('Counter:', this.counter());
    });
  }
  
  // ===== Effect with Cleanup =====
  
  setupPolling() {
    const isPolling = signal(true);
    
    effect((onCleanup) => {
      if (!isPolling()) return;
      
      const interval = setInterval(() => {
        console.log('Polling...');
      }, 5000);
      
      // Cleanup runs when:
      // 1. Effect re-runs (dependency changed)
      // 2. Effect is destroyed
      onCleanup(() => {
        clearInterval(interval);
        console.log('Polling stopped');
      });
    });
  }
  
  // ===== Effect Options =====
  
  // Allow writing to signals (use sparingly!)
  effectWithWrites = effect(() => {
    const count = this.counter();
    // Normally forbidden, but allowed with option
    this.derivedValue.set(count * 2);
  }, { allowSignalWrites: true });
  
  derivedValue = signal(0);
  
  // ===== untracked() - Exclude Dependencies =====
  
  logEffect = effect(() => {
    // counter is tracked - effect reruns on change
    const count = this.counter();
    
    // name is NOT tracked - won't trigger rerun
    const currentName = untracked(() => this.name());
    
    console.log(`${currentName}: ${count}`);
  });
  
  // ===== Manual Effect Management =====
  
  private effectRef = effect(() => {
    console.log('Value:', this.counter());
  });
  
  stopEffect() {
    this.effectRef.destroy();
  }
  
  // ===== Effect with Injector =====
  
  // For effects created outside injection context
  createEffectOutsideConstructor(injector: Injector) {
    effect(() => {
      console.log('Count:', this.counter());
    }, { injector });
  }
  
  // ===== Best Practices =====
  
  // ❌ AVOID: Writing signals in effects
  badEffect = effect(() => {
    this.otherSignal.set(this.counter() * 2); // Creates coupling!
  }, { allowSignalWrites: true });
  
  // ✅ BETTER: Use computed for derived state
  goodDerived = computed(() => this.counter() * 2);
  
  // ❌ AVOID: Fetching in effects (unless necessary)
  fetchEffect = effect(() => {
    fetch(`/api/item/${this.itemId()}`); // Hard to track!
  });
  
  // ✅ BETTER: Use resource() for data fetching
  itemResource = resource({
    request: () => ({ id: this.itemId() }),
    loader: async ({ request }) => {
      const res = await fetch(`/api/item/${request.id}`);
      return res.json();
    }
  });
  
  // ===== When to Use Effects =====
  
  // 1. Logging / debugging
  effect(() => console.log('Debug:', this.state()));
  
  // 2. Syncing with external systems
  effect(() => localStorage.setItem('state', JSON.stringify(this.state())));
  
  // 3. Manual DOM manipulation (rare)
  effect(() => {
    document.title = `Count: ${this.counter()}`;
  });
  
  // 4. Starting/stopping subscriptions
  effect((onCleanup) => {
    const sub = someObservable$.subscribe(/* ... */);
    onCleanup(() => sub.unsubscribe());
  });
}