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: 0Effect 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());
});
}