Ch. 4 · Angular

Angular Effects and External Synchronization

What effect() is actually for in Angular: pushing signal state to localStorage, the DOM or a chart library, with cleanup, untracked and timing.

~6 min readbeginnerupdated Oct 7, 2026

effect() is the part of the signals API people reach for too early. It feels like the signal version of ngOnChanges or subscribe(), so it ends up holding logic that has nothing to do with the outside world. Interviewers know this, which is why “when would you not use an effect?” is a more common question than “what is an effect?”.

The short version: an effect is a bridge from the signal graph to something that isn’t a signal. Saving a preference to localStorage, setting document.title, calling chart.update() on a canvas library, logging. If the destination is another signal, you almost always want computed() instead.

The examples target Angular 19 and 20. The component experiment below was run under Node with Angular 20.3’s TestBed, and the log output is copied from that run.

A preferences component that writes to storage

Here’s a realistic job for an effect. The user picks a theme, and you want the choice persisted so it survives a reload:

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

@Component({
  selector: 'app-prefs',
  template: `{{ theme() }}`,
})
export class Prefs {
  theme = signal(localStorage.getItem('theme') ?? 'light');
  fontSize = signal(14);

  constructor() {
    effect((onCleanup) => {
      const t = this.theme();                       // tracked
      const size = untracked(() => this.fontSize()); // read, but not tracked
      localStorage.setItem('theme', t);
      console.log(`write theme=${t} size=${size}`);
      onCleanup(() => console.log(`cleanup for ${t}`));
    });
  }
}
TypeScript

localStorage isn’t reactive. Nothing in the signal graph can write to it for you. That’s the defining feature of a legitimate effect: the work has to happen, and its target lives outside Angular’s reactivity.

What actually happens when it runs

Drive that component from a test: set the theme twice in a row, change the font size, then destroy it. This is the full log:

created
write theme=light size=14
after first detectChanges
set twice
cleanup for light
write theme=sepia size=14
font changed
cleanup for sepia
destroyed
Text

Four things in that log are worth explaining in an interview.

Effects don’t run when you create them. “created” comes before the first write. Angular schedules the effect, and component effects run as part of change detection. In a test that means fixture.detectChanges() (or TestBed.tick() in Angular 20; TestBed.flushEffects() in 19). If a test asserts on the side effect before that, it fails, and people conclude the effect is broken when it simply hasn’t run yet.

Effects coalesce. The test set the theme to 'dark' and then to 'sepia' before change detection ran. There’s no write for 'dark' at all. The effect saw only the latest value. That’s fine for persisting a preference, and it’s exactly wrong if you were hoping to use an effect as an event log. Signals model state, not a stream of events; if every intermediate value matters, use an RxJS Subject or call a method directly.

Cleanup runs before the next run and on destroy. “cleanup for light” appears just before the sepia write, and “cleanup for sepia” appears when the component is destroyed. This is where you cancel a timer, abort a fetch, or call chart.destroy(). The cleanup closes over the values from its own run, so it knows which theme it’s cleaning up after.

untracked() reads don’t create dependencies. Changing fontSize to 18 produced no write at all, because the effect read it inside untracked(). That’s how you include a value in the side effect without letting it trigger the side effect.

Where an effect is allowed to live

effect() needs an injection context, because it registers itself with the component (or the root) so Angular knows when to run it and when to destroy it. Calling it from a click handler or ngOnInit fails. The real message, from Angular 20.3:

NG0203: effect() can only be used within an injection context such as a constructor,
a factory function, a field initializer, or a function used with `runInInjectionContext`.
Text

So create effects in the constructor or as a field initializer. If you truly need one later, pass an injector explicitly: effect(fn, { injector: this.injector }), with injector = inject(Injector) captured as a field.

An effect created in a component is destroyed with that component; you don’t unsubscribe from anything. An effect created in a root service lives as long as the application. That difference matters if the service effect captures something with a shorter life.

The copy-into-another-signal anti-pattern

This works, and that’s the problem:

a = signal(1);
b = signal(0);

constructor() {
  effect(() => this.b.set(this.a() * 2));
}
TypeScript

In Angular 19 and 20 writing to signals inside an effect is allowed by default (the old allowSignalWrites flag was deprecated in 19 because it no longer does anything). The test component rendered 10 after a was set to 5, so it “works”. But compare it with b = computed(() => this.a() * 2):

  • The effect version has a gap between a changing and b catching up. Code that reads both in that window sees them disagree.
  • b is writable, so some other code can set it to a value that has nothing to do with a.
  • It costs an extra scheduled run and, in a template, can trigger another round of change detection.

Interviewers often ask you to spot this in a snippet. The answer is to delete the effect and the writable signal and replace both with one computed(). If the value must also be user-editable and reset when the source changes, that’s linkedSignal(), covered in the note on computed signals and derived values.

A second realistic case: a third-party chart

Charting libraries are the classic case where an effect earns its place. The chart instance is imperative: you create it once and call methods on it.

export class SalesChart {
  data = input.required<number[]>();
  private canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
  private chart?: Chart;

  constructor() {
    afterNextRender(() => {
      this.chart = new Chart(this.canvas().nativeElement, { type: 'line', data: { datasets: [] } });
    });
    effect(() => {
      const points = this.data();
      if (!this.chart) return;
      this.chart.data.datasets = [{ data: points }];
      this.chart.update();
    });
    inject(DestroyRef).onDestroy(() => this.chart?.destroy());
  }
}
TypeScript

This snippet is illustrative (the chart API is a stand-in). Notice that the effect reads data() before the early return. If you put the read after if (!this.chart) return, the first run returns before reading anything, the effect has no dependencies, and it never runs again. That bug shows up regularly in code reviews. When the work needs the rendered DOM, Angular 19 and 20 also offer afterRenderEffect(), which runs after rendering instead of during change detection.

Interview practice

You’re reviewing this code in a search page. What’s wrong, and what would you write instead?

query = signal('');
results = signal<Product[]>([]);

constructor() {
  effect(() => {
    this.http.get<Product[]>(`/api/search?q=${this.query()}`)
      .subscribe((r) => this.results.set(r));
  });
}
TypeScript

Answer

The effect is doing two jobs, and it does both poorly. Because effects coalesce, fast typing skips intermediate queries, which is fine, but nothing cancels an in-flight request when the query changes, so an older, slower response can arrive last and overwrite newer results. There’s no onCleanup to unsubscribe, so requests also outlive the component that started them. And the query isn’t encoded, so & or # in the input breaks the URL.

I’d first say that fetching data is async state, not a side effect, and Angular has tools for it. Since Angular 19.2, httpResource() (experimental in 19 and 20, like resource()) takes a reactive request, cancels the previous load when it changes, and exposes value(), isLoading() and error() as signals. An RxJS version is equally valid: toObservable(this.query) piped through debounceTime(300) and switchMap to the HTTP call, then toSignal() to bring it back. Both cancel stale requests.

If the team insisted on keeping an effect, the minimum fix is to keep the subscription and call onCleanup(() => sub.unsubscribe()), which cancels the previous request when the query changes and on destroy, and to build the URL with HttpParams. But I’d present that as a patch, not the design I’d choose.

Further reading

More in Angular

read ✓Angular · mid

Angular Pure and Impure Pipes

Why a pure pipe ignores array.push(), what pure: false costs per change detection pass, and why reading a signal inside transform goes stale.

~6 min readread →
read ✓Angular · hard

Angular Resource API for Async Data

How resource() turns a signal-driven request into value, status and error signals, cancels stale loads, and why value() can throw on error.

~6 min readread →
read ✓Angular · hard

Angular Signal Forms Overview

How Signal Forms wrap your model signal in a FieldTree, keep validation in one schema, and map server errors onto fields when you submit.

~6 min readread →
esc