effect() runs a function once, then again every time a signal it read changes. It’s the bridge between the reactive world (signals) and the world that knows nothing about signals: manual DOM, localStorage, third-party libraries, analytics, the console.
An effect moves data from signals to the outside. If you use it to set another signal, you almost certainly needed a computed() or a linkedSignal().
Syntax
You create the effect in an injection context (the constructor or a field initialiser). The effect is automatically tied to the lifecycle of the component or service: when that is destroyed, so is the effect.
import { Component, effect, signal } from '@angular/core';
@Component({ /* ... */ })
export class Settings {
readonly theme = signal<'light' | 'dark'>('light');
constructor() {
// Runs once on creation, then every time `theme` changes.
effect(() => {
document.documentElement.dataset['theme'] = this.theme();
localStorage.setItem('theme', this.theme());
});
}
}When exactly does it run?
- Not synchronously: if you set a signal three times in a row, the effect runs once, with the final value.
- Component effects run during change detection, before the component’s template is checked.
- Effects in root services run as microtasks, before change detection.
- Dependencies are dynamic: only the signals read in the last run count.
Cleanup: onCleanup
The effect receives an onCleanup function. The callback you register runs before the next execution and on destruction. This is where you stop timers, unsubscribe listeners, cancel requests.
effect((onCleanup) => {
if (!this.running()) return;
const id = setInterval(() => this.ticks.update((t) => t + 1), this.speed());
// Called before the next run of the effect
// and when the component / service is destroyed.
onCleanup(() => clearInterval(id));
});- Press “Start”. Change the interval while it runs.
afterNextRender & afterRenderEffect: when you need the DOM
If you need to measure or modify the DOM after Angular has updated it, use the render hooks. afterNextRender runs only once (perfect for initialising a charts or maps library), and afterRenderEffect is an effect that runs after rendering, only when its signals have changed. Both run only in the browser (not on the server during SSR).
import { afterNextRender, afterRenderEffect, ElementRef, viewChild } from '@angular/core';
export class AutoGrowTextarea {
private readonly area = viewChild.required<ElementRef<HTMLTextAreaElement>>('area');
readonly text = signal('');
constructor() {
// Runs AFTER Angular has updated the DOM, whenever `text` changes.
afterRenderEffect(() => {
this.text();
const el = this.area().nativeElement;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
});
// Once, after the first render (ideal for libraries that need the DOM).
afterNextRender(() => {
this.chart = new ThirdPartyChart(this.canvas().nativeElement);
});
}
}
// With explicit phases — avoids “layout thrashing” when you have many components:
afterRenderEffect({
earlyRead: () => this.host.nativeElement.getBoundingClientRect().width, // reads the DOM
write: (width) => { this.panel().nativeElement.style.width = `${width()}px`; }, // writes the DOM
});Anti-patterns (and what to use instead)
1. Syncing between signals
// ✓ computed for derived state
readonly fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
// ✓ linkedSignal if the derived value must also be editable
readonly selected = linkedSignal(() => this.options()[0]);// ✗ effect used to “synchronise” state
readonly firstName = signal('Ana');
readonly lastName = signal('Pop');
readonly fullName = signal('');
constructor() {
effect(() => this.fullName.set(`${this.firstName()} ${this.lastName()}`));
}
// Problems: one extra run, a moment when fullName is stale,
// and a dependency graph that is hard to follow.firstName.set(…)computed()Ana Popeffect() + set()Ana Popeffect runs: 1On screen the two look identical: a component’s effect runs during change detection, before its template is updated. The difference shows up in code that reads the value right after set().
Read in the same click handler, right after set()
- Press a name: you will see what each variant returns at that exact moment.
2. Fetching data from an effect
// ✓ resource / httpResource: automatic cancellation, status, error, reload
readonly user = httpResource<User>(() => `/api/users/${this.userId()}`);// ✗ HTTP triggered from an effect
effect(() => {
const id = this.userId();
this.http.get<User>(`/api/users/${id}`).subscribe((u) => this.user.set(u));
// no cancellation: old responses can arrive after the new ones (race condition)
});The injection context
effect() needs an injector to know when to destroy itself. Outside the constructor you get the error NG0203. The solution: create the effect as a field or in the constructor; in rare cases pass the injector explicitly.
export class Dashboard {
private readonly injector = inject(Injector);
// ✓ in a field initialiser or the constructor: the injection context is available
private readonly logEffect = effect(() => console.log(this.filter()));
ngOnInit() {
// ✗ here you are no longer in an injection context → error NG0203
// effect(() => ...);
// ✓ if you really need it later, pass the injector explicitly
effect(() => console.log(this.filter()), { injector: this.injector });
}
stop() {
this.logEffect.destroy(); // manual stop (rarely needed — it destroys itself with the component)
}
}Before writing an effect, check whether it is really derived state (signal() and computed()) or state that resets (linkedSignal() — editable derived state). Why an effect runs exactly when it does is explained in Signals under the hood.
When YES, when NO
- Persistence:
localStorage,sessionStorage, IndexedDB. - Logging, analytics, telemetry.
- Imperative libraries: charts (Chart.js), maps (Leaflet), editors, canvas.
- Browser APIs:
document.title, focus, scroll, classes on<html>. - Syncing with non-Angular code (an old store, a jQuery widget).
- Derived state →
computed(). - Editable / resettable derived state →
linkedSignal(). - Requests when a parameter changes →
resource()/httpResource(). - Reacting to a click → put the logic directly in the event handler.
- Form validation → Signal Forms have their own rules.
Common pitfalls
- Infinite loops: an effect that reads and writes the same signal can re-trigger endlessly. Use
untracked()to read the current value or, better, rethink the flow. - Hidden dependencies: if you call a method that reads signals internally, those become dependencies too. Wrap the call in
untracked(() => ...). - Expecting a synchronous run: after
signal.set()the effect has NOT run yet. Don’t write code that depends on it. - effect in ngOnInit: an injection-context error. Move it to the constructor.
Cheat sheet
effect(() => { /* reads signals, performs side effects */ });
effect((onCleanup) => { const id = setInterval(...); onCleanup(() => clearInterval(id)); });
effect(fn, { injector, manualCleanup: true, debugName: 'sync-storage' });
const ref = effect(fn); ref.destroy();
afterNextRender(() => { /* once, after the first render — init a DOM library */ });
afterRenderEffect(() => { /* after every render in which the signals it read have changed */ });
afterRenderEffect({ earlyRead, write, mixedReadWrite, read });
// Rule: effect = “reactive world → non-reactive world”. Never signal → signal.