Lesson 2
Effects, Lifecycle & Untracked
The Core Idea
tap() inside a subscription - the place where you interact with non-reactive APIs (logging, localStorage, DOM, third-party libraries). It is not for deriving state.
effect() and NgRx's createEffect() share the name "effect" and the concept of "do something in response to a change," but they operate at different layers. Angular's effect() is a framework primitive - it reacts to signal value changes and runs locally within a component or service lifecycle. NgRx Effects react to dispatched actions, orchestrate async workflows (API calls, navigation), and typically dispatch further actions back into the store. Think of Angular's effect() as the low-level "sync to the outside world" tool, and NgRx Effects as a higher-level architectural pattern for managing complex async flows. We'll revisit this distinction in the SignalStore lessons, where rxMethod bridges the two models.
In the RxJS world, you'd subscribe to an observable and do something with the value - log it, save it to localStorage, update a chart. In the signals world, effect() fills that role. But with a critical difference: effects are the last tool you should reach for, not the first.
Creating an Effect
import { signal, effect } from '@angular/core';
const count = signal(0);
effect(() => {
console.log(`The count is: ${count()}`);
});
// Logs immediately: "The count is: 0"
count.set(5);
// Logs again: "The count is: 5"
Key behaviours:
- Runs at least once - the effect executes immediately to establish its dependencies.
- Tracks dynamically - just like
computed(), only signals read during the most recent execution are dependencies. Conditional branches work the same way. - Runs asynchronously - effects don't run synchronously when you call
.set(). They're scheduled to run during Angular's change detection process.
How Does Angular Know Which Signals to Watch?
There's no explicit registration - no "subscribe to this signal" call. The link between a signal and an effect is created purely by reading the signal inside the effect's function body.
The mechanism:
- Angular starts executing the effect function and activates a reactive context (a tracking scope).
- Every signal that gets read (you call
mySignal()) during that synchronous execution registers itself as a dependency. - When any registered signal changes value, the effect is scheduled to re-run.
- On re-run, the dependency set is rebuilt from scratch (dynamic tracking).
const name = signal('Alice');
const age = signal(30);
const theme = signal('dark');
effect(() => {
console.log(`${name()} is ${age()}`);
// name and age are dependencies (they were called)
// theme is NOT a dependency (never called in here)
});
Changing theme will never trigger this effect. Changing name or age will. And because tracking is dynamic, conditional branches change the dependency set:
effect(() => {
if (showDetails()) {
console.log(name(), age()); // showDetails + name + age tracked
} else {
console.log(name()); // only showDetails + name tracked
}
});
If showDetails() returns false, changes to age won't trigger the effect until showDetails flips back to true. This is the same tracking mechanism that powers computed(), and it's the reason untracked() works (covered later in this lesson) - it reads a signal's value without triggering the registration step.
The RxJS Comparison
// RxJS way - subscribe + tap for side effects
this.count$.pipe(
tap(count => console.log(`The count is: ${count}`))
).subscribe();
// Don't forget to unsubscribe!
// Signal way - effect handles it all
effect(() => {
console.log(`The count is: ${this.count()}`);
});
// No subscription. No teardown. Cleaned up automatically.
The effect is destroyed automatically when the component/directive that created it is destroyed. No takeUntilDestroyed(), no ngOnDestroy, no subscription management.
When to Use Effects (and When Not To)
This is the most important section of this lesson. The Angular team is explicit: effects should be your last resort. The official docs say "there are no situations where effect is good, only situations where it is appropriate."
Appropriate uses
| Use case | Example |
|---|---|
| Logging / analytics | Send signal values to a logging service or analytics platform |
| Syncing to external storage | Write to localStorage, sessionStorage, cookies |
| Custom DOM behaviour | Update a canvas, integrate a charting library, manipulate elements that templates can't express |
| Third-party library integration | Push data to a non-Angular library that has its own imperative API |
What NOT to use effects for
effect(() => { someOtherSignal.set(derivedValue()) }), stop. You're creating a chain reaction that Angular can't optimise, risks circular updates, and may trigger ExpressionChangedAfterItHasBeenCheckedError. Use computed() or linkedSignal() (covered in Lesson 3) instead. linkedSignal() is a writable signal that automatically resets its value based on a reactive source - like a computed() that you can also .set() manually when the user overrides it.
// WRONG - using effect to propagate state
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = signal('');
effect(() => {
fullName.set(`${firstName()} ${lastName()}`); // Don't do this!
});
// RIGHT - use computed for derived state
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => `${firstName()} ${lastName()}`);
The heuristic: If the output of your effect is "set another signal" or "change some state", you almost certainly want computed() or linkedSignal() instead. Effects are for crossing the boundary out of the reactive system - into the DOM, into localStorage, into a third-party API, into a log.
Injection Context
Effects must be created within an injection context - meaning inside a constructor, a field initialiser, or somewhere you have access to inject(). This is because effects need Angular's DI to manage their lifecycle (knowing when to destroy them). Using our Policy Dashboard as the example:
@Component({ /* ... */ })
export class PolicyDashboard {
private readonly selectedPolicy = signal<Policy | null>(null);
// Option 1: field initialiser (works because it runs in injection context)
private readonly logEffect = effect(() => {
console.log('Selected policy:', this.selectedPolicy()?.id);
});
// Option 2: constructor
constructor() {
effect(() => {
console.log('Selected policy:', this.selectedPolicy()?.id);
});
}
}
If you need to create an effect later (outside the constructor), pass an Injector:
@Component({ /* ... */ })
export class PolicyDashboard {
private readonly injector = inject(Injector);
private readonly selectedPolicy = signal<Policy | null>(null);
startLogging(): void {
effect(() => {
console.log('Selected policy:', this.selectedPolicy()?.id);
}, { injector: this.injector });
}
}
The RxJS equivalent: This is like how takeUntilDestroyed() needs a DestroyRef when used outside the constructor. Same idea - Angular needs to know the lifecycle scope.
Effect Cleanup
Effects often start operations that need cancelling - timers, network requests, event listeners. The onCleanup callback handles this:
effect((onCleanup) => {
const user = currentUser();
const timer = setTimeout(() => {
console.log(`1 second ago, the user became ${user}`);
}, 1000);
// Called before the next run, or when the effect is destroyed
onCleanup(() => {
clearTimeout(timer);
});
});
onCleanup is called in two situations:
- Before the effect re-runs - a dependency changed, so the previous operation needs cancelling before the new one starts.
- When the effect is destroyed - the component is being torn down.
Effect Cleanup Lifecycle
The cleanup from the previous run always fires before the effect executes again. This prevents resource leaks (dangling timers, stale listeners, orphaned requests).
The RxJS Comparison
// RxJS way - switchMap cancels the previous inner observable
this.currentUser$.pipe(
switchMap(user => timer(1000).pipe(
tap(() => console.log(`1 second ago, the user became ${user}`))
)),
takeUntilDestroyed()
).subscribe();
// Signal way - onCleanup cancels the previous timeout
effect((onCleanup) => {
const user = currentUser();
const timer = setTimeout(() => {
console.log(`1 second ago, the user became ${user}`);
}, 1000);
onCleanup(() => clearTimeout(timer));
});
The switchMap pattern and the onCleanup pattern are solving the same problem: cancel the previous operation when a new one starts. The signal version is more explicit about what's being cleaned up.
Untracked: Reading Without Dependency
Sometimes you need to read a signal inside an effect (or computed) without creating a dependency on it. untracked() does this:
import { signal, effect, untracked } from '@angular/core';
const currentUser = signal('Alice');
const counter = signal(0);
effect(() => {
// This effect should only re-run when currentUser changes.
// counter is read but should NOT trigger a re-run.
console.log(`User set to ${currentUser()} and the counter is ${untracked(counter)}`);
});
Without untracked, this effect would re-run every time either signal changes. With it, only changes to currentUser trigger the effect - counter is read but not tracked.
You can also wrap an entire block:
effect(() => {
const user = currentUser(); // tracked - triggers re-run
untracked(() => {
// Nothing read in here creates a dependency
this.loggingService.log(`User set to ${user}`);
// Even if loggingService internally reads signals, they won't be tracked
});
});
The RxJS Comparison
There's no direct RxJS equivalent because RxJS subscriptions don't have "tracked reads." The closest analogy is using withLatestFrom() vs combineLatest():
// combineLatest - re-emits when EITHER changes (like two tracked reads)
combineLatest([currentUser$, counter$]).pipe(
tap(([user, count]) => console.log(`${user}, ${count}`))
);
// withLatestFrom - only re-emits when currentUser$ emits,
// grabs counter's latest value passively (like untracked)
currentUser$.pipe(
withLatestFrom(counter$),
tap(([user, count]) => console.log(`${user}, ${count}`))
);
So untracked(counter) is the signal equivalent of withLatestFrom(counter$) - "give me the value, but don't make me react to it."
Async and Reactive Context
A critical rule: the reactive context is only active for synchronous code. Signal reads after an await or inside a callback are not tracked:
// BROKEN - theme() is read after await, so it won't be tracked
effect(async () => {
const data = await fetchUserData();
console.log(`User: ${data.name}, Theme: ${theme()}`); // theme not tracked!
});
// FIXED - read all signals before the await
effect(async () => {
const currentTheme = theme(); // tracked - read before await
const data = await fetchUserData();
console.log(`User: ${data.name}, Theme: ${currentTheme}`);
});
This applies to all reactive contexts - effect(), computed(), templates. The tracking only works synchronously during the initial execution. Anything after an async boundary is invisible to the dependency graph.
await, the execution has already "returned" from Angular's perspective - the reactive context is closed. The callback after the await runs in a different microtask with no tracking active. This is a fundamental constraint of the push-invalidation model, not a bug.
View Effects vs Root Effects
Angular distinguishes between two kinds of effect based on where they're created:
| View Effect | Root Effect | |
|---|---|---|
| Created in | Component, directive, or their services | Root-provided service (outside the component tree) |
| Runs | Before its component is checked by CD | Before all components are checked |
| Destroyed | When the component is destroyed | When the application is destroyed |
In practice, most effects you write will be view effects. Root effects are for application-wide concerns like syncing to localStorage in a global service.
providers array), effects created inside that service share the component's lifecycle - they're destroyed when the component is destroyed. This matters because in well-structured Angular apps, most business logic lives in services injected into components, not in the components themselves.
afterRenderEffect: DOM Side Effects
afterRenderEffect() is not a lifecycle hook - it's a reactive primitive, introduced in Angular 19 alongside the signals system. The distinction matters:
- Lifecycle hooks (
ngOnInit,ngAfterViewInit, etc.) fire at fixed points in a component's life - once, unconditionally, because the component reached that stage. afterRenderEffect()fires reactively whenever its signal dependencies change, but defers execution until after Angular has updated the DOM. It can fire zero times, once, or hundreds of times depending on how its signals change.
It exists because effect() has a timing problem: it runs before Angular updates the DOM. If your side effect needs to read or manipulate the actual rendered DOM - measure an element, update a canvas, interact with a third-party library that needs a real DOM node - effect() is too early. afterRenderEffect() gives you the reactivity of effect() with the timing guarantee that the DOM is current.
The RxJS Comparison
// Old way: lifecycle hook + manual subscription
ngAfterViewInit() {
this.chartData$.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(data => {
this.chart.update(data); // DOM manipulation
});
}
// New way: reactive + DOM-safe, no subscription management
constructor() {
afterRenderEffect(() => {
this.chart.update(this.chartData()); // runs after DOM is updated
});
}
Example: Chart Integration
The example below uses afterNextRender() alongside afterRenderEffect(). Note that afterNextRender() is a different category of thing entirely - it's not reactive (no signal tracking), not a lifecycle hook (no interface to implement), and not part of the signals system. It's a one-shot scheduling utility: "run this callback once after the next render completes, then never again." Think of it as a functional replacement for ngAfterViewInit - same timing, but called in the constructor instead of implemented as an interface method. It pairs naturally with afterRenderEffect() because DOM setup (one-time) and DOM updates (reactive) often go hand in hand.
import { afterRenderEffect, afterNextRender, viewChild, ElementRef } from '@angular/core';
@Component({
template: `<canvas #canvas></canvas>`
})
export class PolicyChart {
chartData = input.required<ChartData>();
canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
chart!: ChartInstance;
constructor() {
// Initial setup - runs once after first render (not reactive)
afterNextRender({
write: () => {
this.chart = initializeChart(this.canvas().nativeElement, this.chartData());
}
});
// Reactive update - re-runs after DOM update whenever chartData changes
afterRenderEffect(() => {
this.chart.updateData(this.chartData());
});
}
}
afterRenderEffect also supports render phases (earlyRead, write, mixedReadWrite, read) to avoid layout thrashing - but for most cases the simple form above is sufficient.
effect()- for non-DOM side effects (logging, storage, analytics)afterRenderEffect()- for DOM manipulation or third-party libraries that need the rendered DOM- Neither - for derived state (use
computed()orlinkedSignal())
Manual Cleanup and Destroying Effects
Effects are normally destroyed automatically with their component. If you need manual control:
const effectRef = effect(() => {
console.log('Policy:', this.selectedPolicy()?.id);
}, { manualCleanup: true });
// Later, when you're done with it:
effectRef.destroy();
The manualCleanup: true option disables automatic destruction when the component dies. You take responsibility for calling .destroy(). Calling .destroy() stops the effect permanently and triggers its onCleanup callback (if registered). It takes no arguments.
When is this useful? When the effect's lifetime doesn't map 1:1 to a component's lifetime - you need to start and stop it independently:
@Component({ /* ... */ })
export class LivePolicyPanel {
private readonly injector = inject(Injector);
private readonly selectedPolicyId = input.required<string>();
private pollingEffect: EffectRef | null = null;
// Start polling when user opens the live panel
openLiveView(): void {
this.pollingEffect = effect(() => {
this.policyService.fetchLatest(this.selectedPolicyId());
}, { manualCleanup: true, injector: this.injector });
}
// Stop polling when they close it - component stays alive
closeLiveView(): void {
this.pollingEffect?.destroy(); // stops effect, runs onCleanup
this.pollingEffect = null;
}
}
Without manualCleanup, the only way to stop this effect would be to destroy the entire component. With it, you get fine-grained control over when the reactive work starts and stops. That said, this is rare - most effects should just live and die with their component.
Quiz
You want to update a chart library whenever your signal-based data changes. Which API do you use?
You read theme() after an await inside an effect. What happens?
You have effect(() => { totalPrice.set(quantity() * unitPrice()) }). What's wrong?
What does untracked(counter) do inside an effect?
Try It Yourself
Exercise 1: localStorage Sync Effect
Build an effect that persists the user's selected theme to localStorage whenever it changes. Requirements:
- Create a writable signal
themewith initial value'light' - Write an effect that saves the theme value to
localStorageunder the key'app-theme' - The effect should NOT re-run when other signals in the component change
Is this an appropriate use of effect(), or should it be a computed()? Why?
import { signal, effect } from '@angular/core';
@Component({ /* ... */ })
export class ThemeManager {
readonly theme = signal<'light' | 'dark'>('light');
constructor() {
effect(() => {
localStorage.setItem('app-theme', this.theme());
});
}
toggle(): void {
this.theme.update(t => t === 'light' ? 'dark' : 'light');
}
}
This is an appropriate use of effect() because localStorage is an external side effect - you're crossing the boundary out of the reactive system. A computed() can't do this because it only derives values, it can't perform side effects. The effect only tracks theme because that's the only signal read in the function body.
Exercise 2: Cleanup Challenge
You're building a live policy feed that polls an API every 5 seconds. When the selected policy changes, the old polling interval should be cancelled and a new one started. Write the effect with proper cleanup:
// Fill in the effect body
const selectedPolicyId = signal('POL-001');
effect((onCleanup) => {
// TODO: read the selected policy ID
// TODO: start a setInterval that polls every 5 seconds
// TODO: register cleanup to cancel the interval
});
const selectedPolicyId = signal('POL-001');
effect((onCleanup) => {
const policyId = selectedPolicyId(); // tracked dependency
const intervalId = setInterval(() => {
console.log(`Polling updates for ${policyId}...`);
// In reality: this.policyService.fetchLatest(policyId);
}, 5000);
onCleanup(() => {
clearInterval(intervalId); // cancel previous polling
});
});
When selectedPolicyId changes: (1) onCleanup fires, clearing the old interval, (2) the effect re-runs, reading the new ID and starting a fresh interval. When the component is destroyed, onCleanup fires one final time. No orphaned intervals.
Exercise 3: Spot the Bug
This effect is supposed to log analytics when the user navigates to a new policy, including a timestamp. But something's wrong with the dependency tracking. Find and fix the bug:
const selectedPolicy = signal<Policy | null>(null);
const currentTimestamp = signal(Date.now()); // updated every second elsewhere
effect(() => {
const policy = selectedPolicy();
if (policy) {
analytics.track('policy_viewed', {
policyId: policy.id,
viewedAt: currentTimestamp(), // we want the timestamp at view time
});
}
});
Hint: how often should this analytics event fire? What's actually triggering it?
effect(() => {
const policy = selectedPolicy();
if (policy) {
analytics.track('policy_viewed', {
policyId: policy.id,
viewedAt: untracked(currentTimestamp), // read but don't track
});
}
});
The bug: currentTimestamp() is a tracked dependency that updates every second. So the analytics event fires every second while a policy is selected - not just when the user navigates. The fix: wrap it in untracked() so we get the timestamp value without making it a dependency. The effect now only re-runs when selectedPolicy changes.
Your Tangible Win
You now know the three tools in the signal side-effect toolkit: effect() for non-DOM side effects, afterRenderEffect() for DOM work, and untracked() for reading without dependency. More importantly, you know when not to use effects - which is most of the time. If you can express it as computed(), do that instead.
Next lesson, we'll look at linkedSignal() - the writable computed that fills the gap between computed() (read-only) and signal() (no reactive source).