Ein Effect ist die Brücke zwischen dem reaktiven Signal-Graphen und der Außenwelt — alles, was nicht reine Wertableitung ist: Logging, DOM-Manipulation, LocalStorage-Sync, Analytics-Events, Subscriptions auf nicht-reaktive APIs. effect() läuft initial einmal und danach automatisch bei jeder Änderung der Signals, die es liest.
Während computed() für reine, lazy ausgewertete Ableitungen gedacht ist, ist effect() eager und für unreine Operationen reserviert. Das bringt Verantwortung mit: falsche Verwendung führt zu Memory-Leaks, Endlos-Schleifen oder unerwarteten Re-Runs. Dieser Artikel zeigt dir, wo effect() richtig sitzt, wie Cleanup und EffectRef funktionieren, was es mit allowSignalWrites auf sich hat — und welche Patterns du besser mit computed, linkedSignal oder resource() löst.
Eager Side-Effect-Listener
effect(fn) registriert eine Funktion, die Angular automatisch ausführt — initial einmal, und danach bei jeder Änderung eines Signals, das sie gelesen hat. Anders als computed() ist ein Effect nicht lazy und liefert keinen Rückgabewert: Er existiert nur für seine Wirkung, nicht für sein Ergebnis.
| Aspekt | computed() | effect() |
|---|---|---|
| Ausführung | Lazy (beim Lesen) | Eager (initial + bei Source-Änderung) |
| Reinheit | Pure Pflicht | Side-Effects gewollt |
| Rückgabewert | Signal<T> | EffectRef |
| Sinn | Wertableitung | Wirkung in der Außenwelt |
| Cleanup | Nicht nötig | onCleanup für Disposables |
| Lifecycle-Bindung | Folgt dem Read | Folgt dem Injection-Context-Owner |
Injection-Context als harte Voraussetzung
effect() braucht zur Erstellung einen aktiven Injection-Context — Angular muss wissen, an welchen Lifecycle der Effect gebunden ist, um ihn beim Destroy automatisch zu stoppen. Es gibt drei legitime Aufrufstellen:
- Im Constructor einer Component, Directive oder Pipe.
- Als Field-Initializer einer Klasse, deren Instanziierung im Injection-Context passiert.
- In
providedIn: 'root'-Services oder überrunInInjectionContext(injector, () => effect(...)).
import { Component, signal, effect, inject, Injector, runInInjectionContext } from '@angular/core';
@Component({
selector: 'app-effect-locations',
standalone: true,
template: `<button (click)="laterEffect()">Effect später erstellen</button>`,
})
export class EffectLocationsComponent {
private injector = inject(Injector);
count = signal(0);
// 1) Field-Initializer — geht, weil die Klasse im Injection-Context instanziiert wird.
private logger = effect(() => console.log('count:', this.count()));
constructor() {
// 2) Constructor — klassischer Ort.
effect(() => document.title = `Count: ${this.count()}`);
}
laterEffect() {
// 3) Ausserhalb — nur über runInInjectionContext oder die injector-Option.
runInInjectionContext(this.injector, () => {
effect(() => console.log('späterer Effect:', this.count()));
});
}
}Logging, document.title, LocalStorage
Drei alltägliche Side-Effect-Use-Cases, jeweils mit dem minimalen Effect-Pattern:
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-basic-effects',
standalone: true,
template: `
<input [value]="title()" (input)="title.set($any($event.target).value)" />
<button (click)="counter.update((n) => n + 1)">+1</button>
<p>{{ counter() }}</p>
`,
})
export class BasicEffectsComponent {
counter = signal(0);
title = signal('Mibeon');
theme = signal<'light' | 'dark'>('light');
constructor() {
// 1) Logging — läuft initial + bei jeder counter-Änderung.
effect(() => console.log('counter:', this.counter()));
// 2) DOM-Effect: Browser-Tab-Titel synchron halten.
effect(() => {
document.title = `${this.title()} — Counter ${this.counter()}`;
});
// 3) Persistenz: Theme in localStorage spiegeln.
effect(() => {
localStorage.setItem('theme', this.theme());
});
}
}Der Reiz: keine subscribe-Calls, keine takeUntilDestroyed()-Pipes, kein manuelles Aufräumen. Die Effects werden mit der Component zerstört, sobald sie aus dem DOM verschwindet.
onCleanup für Disposables
Wenn dein Effect Ressourcen anlegt, die explizit aufgeräumt werden müssen — Intervals, Timeouts, EventListener, Manuel-Subscriptions, AbortControllers — registrierst du den Aufräum-Callback über den onCleanup-Parameter. Er läuft vor jedem nächsten Run des Effects UND beim Destroy des umgebenden Lifecycle-Owners.
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-cleanup',
standalone: true,
template: `
<button (click)="active.set(!active())">
{{ active() ? 'Stoppen' : 'Starten' }}
</button>
<p>Ticks: {{ ticks() }}</p>
`,
})
export class CleanupComponent {
active = signal(true);
ticks = signal(0);
constructor() {
effect((onCleanup) => {
if (!this.active()) return;
const id = setInterval(() => {
this.ticks.update((n) => n + 1);
}, 1000);
// Wird aufgerufen vor dem nächsten Effect-Run UND beim Destroy.
onCleanup(() => clearInterval(id));
});
}
}Wechselt active() auf false, läuft der Effect erneut, vorher feuert das Cleanup, der alte setInterval wird gestoppt, der neue Lauf macht nichts weiter. Wechselt es wieder auf true, wird ein neues Interval gestartet — perfekt aufgeräumt.
Schreiben aus einem Effect heraus
Per Default verbietet Angular, in einem effect() ein Signal zu schreiben — der Compiler oder die Runtime werfen einen Fehler. Der Grund ist Endlos-Schleifen-Vermeidung: Wenn ein Effect ein Signal liest und schreibt, das er gleichzeitig liest, wäre die Schleife sofort da.
Du kannst die Sperre per Option lösen: effect(fn, { allowSignalWrites: true }). Mach das nur, wenn du genau weißt, was du tust — und am liebsten gar nicht. Für State-Synchronisation ist seit v19 linkedSignal der idiomatische Weg.
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-idle',
standalone: true,
template: `<p>Nutzer ist {{ active() ? 'aktiv' : 'idle' }}</p>`,
})
export class IdleComponent {
lastInteraction = signal(Date.now());
active = signal(true);
constructor() {
// Legitimer Use-Case: Idle-Tracker, der einen abgeleiteten Status setzt.
effect(
(onCleanup) => {
const last = this.lastInteraction();
const id = setTimeout(() => this.active.set(false), 60_000);
onCleanup(() => clearTimeout(id));
// Beim ersten Lauf aktiv setzen
this.active.set(Date.now() - last < 60_000);
},
{ allowSignalWrites: true }
);
}
}.destroy() für kurzlebige Effects
effect() gibt eine EffectRef zurück. Mit .destroy() kannst du einen Effect manuell beenden — auch bevor der umgebende Lifecycle endet.
import { Injectable, signal, effect, EffectRef } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class TrackingService {
private trackingActive = signal(false);
private effectRef: EffectRef | null = null;
startTracking(eventStream: () => string) {
if (this.effectRef) return;
this.effectRef = effect(() => {
const event = eventStream();
if (event) console.log('track:', event);
});
}
stopTracking() {
this.effectRef?.destroy();
this.effectRef = null;
}
}Der typische Use-Case sind Effects in Services, deren Aktivität an Domain-Events gebunden ist (Login/Logout, Tab sichtbar/versteckt, Feature-Flag an/aus). Ohne .destroy() würden sie bis zum Neuladen der App weiterlaufen.
Effects außerhalb des klassischen Contexts
Manchmal willst du einen Effect später erstellen, etwa innerhalb einer asynchronen Methode oder eines Custom-Hooks. Dafür gibt es die injector-Option:
import { Component, inject, Injector, signal, effect } from '@angular/core';
@Component({
selector: 'app-injector-option',
standalone: true,
template: `<button (click)="setupLater()">Effect erst nach Klick</button>`,
})
export class InjectorOptionComponent {
private injector = inject(Injector);
value = signal(0);
async setupLater() {
// Async-Code: ab hier ist kein automatischer Injection-Context mehr aktiv.
await new Promise((r) => setTimeout(r, 500));
// injector-Option umgeht die Pflicht zum Injection-Context.
effect(() => console.log('value:', this.value()), { injector: this.injector });
}
}Drei Effects in einer Komponente
Eine Komponente, die parallel loggt, den Tab-Titel setzt und in den LocalStorage persistiert — mit einem zusätzlichen, manuell verwalteten Effect:
import { Component, signal, effect, EffectRef } from '@angular/core';
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
<input
[value]="username()"
(input)="username.set($any($event.target).value)"
/>
<button (click)="toggleAnalytics()">
Analytics: {{ analyticsRef() ? 'an' : 'aus' }}
</button>
`,
})
export class DashboardComponent {
username = signal('');
analyticsRef = signal<EffectRef | null>(null);
constructor() {
// Logger
effect(() => console.log('username:', this.username()));
// Tab-Titel synchron halten
effect(() => {
document.title = this.username()
? `Hallo, ${this.username()}`
: 'Mibeon';
});
// Persistenz mit Cleanup beim nächsten Lauf (für AbortController-Patterns)
effect((onCleanup) => {
const ctrl = new AbortController();
localStorage.setItem('user', this.username());
onCleanup(() => ctrl.abort());
});
}
toggleAnalytics() {
const current = this.analyticsRef();
if (current) {
current.destroy();
this.analyticsRef.set(null);
return;
}
const ref = effect(() => {
if (this.username()) navigator.sendBeacon('/track', this.username());
});
this.analyticsRef.set(ref);
}
}Wann effect() das falsche Werkzeug ist
Drei häufige Fehlanwendungen — und ihre besseren Alternativen:
| Symptom | Anti-Pattern | Besser |
|---|---|---|
| „Wenn A sich ändert, Signal B mitziehen” | effect(() =>…b.set(...)) mit allowSignalWrites | linkedSignal oder computed |
| „Bei Signal-Änderung neu rendern” | effect(() => this.cdr.markForCheck()) | Template-Bindings — Signals triggern automatisch |
| „HTTP-Request abfeuern, wenn Signal sich ändert” | effect(() => http.get(url()).subscribe(...)) | resource() / rxResource() |
| „Initial-Daten laden in der Component” | effect(() => loadOnce()) mit Guard-Flag | Resolver oder rxResource() mit Trigger-Signal |
import { Component, signal, effect, linkedSignal } from '@angular/core';
@Component({ standalone: true, selector: 'app-bad-vs-good', template: '' })
export class BadVsGoodComponent {
options = signal<string[]>(['a', 'b', 'c']);
// SCHLECHT: Effect mit allowSignalWrites, um eine Auswahl zu spiegeln
selected = signal('a');
constructor() {
effect(
() => {
const opts = this.options();
if (!opts.includes(this.selected())) this.selected.set(opts[0] ?? '');
},
{ allowSignalWrites: true }
);
}
// GUT: linkedSignal — beschreibt die Beziehung deklarativ.
selectedV2 = linkedSignal({
source: this.options,
computation: (opts, prev) =>
prev && opts.includes(prev.value) ? prev.value : opts[0] ?? '',
});
}Häufige Fehler
effect() ohne Injection-Context
effect() in einer Methode aufrufen, die durch ein User-Event getriggert wird, wirft NG0203: inject() must be called from an injection context. Lösung: entweder im Constructor erstellen, einen Injector per inject(Injector) holen und mit runInInjectionContext wrappen oder die { injector }-Option an effect() übergeben.
Initial-Run wird vergessen
Ein Effect läuft AUTOMATISCH einmal beim Erstellen — auch wenn keine Source noch nie geändert wurde. Code, der nur auf späteren Änderungen basiert (z. B. „erst zeigen, wenn Nutzer etwas eingibt”), muss eine Bedingung am Anfang prüfen, etwa if (this.value() === ”) return;.
Vergessenes onCleanup → Memory-Leak
setInterval, setTimeout, addEventListener, manuelle subscribe-Aufrufe, WebSockets — alle brauchen ein onCleanup(() => …). Vergisst du es, läuft das Disposable beim nächsten Effect-Run zusätzlich an, und beim Destroy bleibt es ganz hängen. Lifecycle-Bindung deckt nur den Effect ab, nicht die von ihm angelegten Ressourcen.
allowSignalWrites: true für State-Sync
Die Option ist ein Anti-Pattern in 90 % der Fälle, in denen sie auftaucht. Wenn du ein Signal aus einem Effect schreibst, weil sich ein anderes geändert hat, ist linkedSignal oder computed das passende Werkzeug. allowSignalWrites ist nur für echte Side-Effect-Cases gedacht (Timer, externe APIs), bei denen Lesen und Schreiben fundamental verschränkt sind.
Conditional Tracking führt zu unerwarteten Re-Runs
Wie bei computed trackt ein Effect dynamisch nur die Signals, die er im aktuellen Lauf gelesen hat. Wechselt eine Bedingung den Branch, kommen neue Quellen hinzu, alte fallen raus. Ergebnis: Ein bisher stabiler Effect kann nach einer Bedingungs-Änderung plötzlich häufig laufen, weil eine neue Quelle viele Updates hat.
Effect läuft nicht synchron beim set()
Mehrere signal.set()-Aufrufe in derselben synchronen Sequenz triggern den Effect nicht mehrfach — er wird im nächsten Microtask gebatcht ausgeführt. Code, der direkt nach this.x.set(1); this.y.set(2); auf den Effect-Output zugreifen will, muss await Promise.resolve() einschieben oder eben computed() nutzen.
Endlos-Schleifen mit deep-equal-Outputs
Der Endlos-Schutz greift, wenn ein geschriebenes Signal denselben Wert (per Object.is) wie zuvor erhält. Bei Custom-Equality-Funktionen oder bei Objekten, die strukturell gleich, aber referenziell verschieden sind, greift der Schutz nicht — die Schleife läuft munter weiter, bis der Browser den Tab killt. Setze in allowSignalWrites-Effects niemals einen frischen Object/Array-Literal, ohne Equality-Check.
Effects in Tests brauchen TestBed.tick()
TestBed instanziiert Components inklusive ihrer Effects, aber der Initial-Run wird erst über TestBed.tick() (ab v18+) oder fixture.detectChanges() für Component-Effects geflusht. Reine const ref = effect(…)-Asserts ohne Flush sehen den Effect-Output nie.
Weiterführende Ressourcen
Externe Quellen
- Effects – Angular.dev
- effect() API Reference – Angular.dev
- EffectRef API Reference – Angular.dev
- Signals Guide – Angular.dev