Values vs. actions
Module 2 was about producing values: a computed describes what something is, and Angular keeps it correct. But some work isn't a value at all. Writing to localStorage, sending an analytics ping, logging, focusing an input, opening a websocket — these are actions. They have no return value worth caching, and running them twice is not the same as running them once.
That is what effect() is for: a block of code that re-runs whenever the signals it reads change.
import { signal, effect } from '@angular/core';
const query = signal('shoes');
// Schedules an initial run, then reacts to changes in tracked signals.
// Create this inside an injection context (or pass an injector option).
effect(() => {
console.log('searching for', query());
});
query.set('boots'); // effect runs againWhere you create one
effect() must be created in an injection context, so that Angular knows what owns it and when to tear it down. A field initializer or constructor in a component, directive or service is a valid context. Anywhere else, pass an injector explicitly.
@Component({ /* ... */ })
export class SearchBox {
private readonly query = signal('');
// A field initializer runs inside an injection context, so this is fine.
private readonly logger = effect(() => console.log(this.query()));
}
// Outside an injection context you must pass one explicitly:
effect(() => { /* ... */ }, { injector: this.injector });When does it actually run?
An effect is scheduled for an initial execution even if no dependency changes. Registration does not synchronously run the callback. Several synchronous writes before it executes can coalesce into one run with the latest value. In Angular 21, component effects run during Angular synchronization; root effects run as microtasks.
const count = signal(0);
effect(() => console.log('count is', count()));
count.set(1);
count.set(2);
count.set(3);
// Logs "count is 3" ONCE — not three times.
//
// Effects are SCHEDULED, not synchronous. Angular coalesces the writes and
// runs the effect once, with the final value. You never see an intermediate
// state in this run. This is coalescing, not an atomic transaction:
// ordinary synchronous reads between separate writes can see intermediate values.Effect run log
This effect reads count() and nothing else. Watch which buttons make it run.
Effect output
- run #1 — count() is 0
There is already a run #1 in the log before you touch anything. "Three writes at once" adds one line, not three. Changing the note adds none.
Cleaning up with onCleanup
If an effect starts something ongoing — a timer, a socket, a listener — it has to be able to stop it. The effect function receives an onCleanup callback for exactly that.
effect((onCleanup) => {
const room = roomId();
const socket = chat.connect(room);
onCleanup(() => socket.close());
});
// onCleanup runs:
// 1. before EVERY subsequent run of the effect, and
// 2. once when the effect is destroyed.
//
// So switching rooms closes the old socket before opening the new one, and
// leaving the page closes the last one. No leaks, no manual bookkeeping.Chat room connection
Switching rooms tears down the old connection before building the new one. The timer proves the old one really stopped.
Connection log
- 🔌 connected to #general
Every "connected" is preceded by a "cleanup" for the room before it, and the seconds counter restarts from zero — the old interval is genuinely cleared, not just ignored.
Reading without subscribing: untracked()
An effect tracks synchronous signal reads, including reads in functions it calls. Reads after an await or inside a later timer callback are not tracked. Sometimes that's too much: you want a value at the moment the effect runs, but you don't want changes to that value to trigger a run. Wrap the read in untracked().
effect(() => {
const status = orderStatus(); // tracked -> re-runs
const name = untracked(() => customerName()); // read, NOT tracked
sendEmail(name, status);
});
// Changing orderStatus sends an email.
// Changing customerName does NOT -- but the next email uses the new name.Order notifications
The email should go out when the ORDER changes — not every time the customer edits their name.
Emails sent
- ✉️ to Ada: your order is now "draft"
Rename the customer a few times — no new emails. Then advance the status: the email that goes out uses the latest name. Untracked means "don't wake me for this", not "don't read this".
When not to reach for an effect
This is the most valuable idea in the module. Effects are the escape hatch from the reactive graph, and beginners reach for them constantly — most often to copy one signal into another.
// ❌ Using an effect to keep one signal in sync with another.
const items = signal<Item[]>([]);
const itemCount = signal(0);
effect(() => itemCount.set(items().length));
// Problems: itemCount is briefly stale, anyone can write nonsense into it,
// and you have created a second source of truth for one fact.
// ✅ Derive it. One source of truth, always correct, no scheduling gap.
const itemCount = computed(() => items().length);The computed version isn't merely tidier. It has no scheduling gap, so it can never be read while stale; it can't be written to by mistake; and there is exactly one source of truth. Prefer effects for synchronizing with imperative APIs. A signal write inside one can be justified for reporting asynchronous work, but pure derivation belongs in a computed or linkedSignal.
// Ask: am I producing a VALUE, or performing an ACTION?
computed() -> a value derived from other signals (pure, cached)
effect() -> an action to run when state changes (impure, scheduled)
// Producing a value? computed()
// Logging / analytics? effect()
// Writing to localStorage? effect()
// Pure signal derivation? computed()
// Derived with overrides? linkedSignal()
// Reading/writing the DOM? afterRenderEffect()
// Derived but overridable? linkedSignal() -- Module 7Stopping an effect early
effect() returns an EffectRef. You rarely need it — destruction is automatic — but it's there when an effect should stop early. Keeping one beyond its normal lifetime requires manualCleanup and explicit destruction.
// Created in a component/service -> destroyed with it. Nothing to do.
export class Widget {
private readonly logger = effect(() => console.log(this.value()));
}
// Need to stop it early? effect() returns an EffectRef.
const ref = effect(() => poll(url()));
ref.destroy();
// Opting out of automatic cleanup (you must then destroy it yourself):
effect(() => { /* ... */ }, { manualCleanup: true, injector: myInjector });🎯 Your turn: debounce an autosave
Here is onCleanup doing real work. Every keystroke re-runs the autosave effect, which schedules a save 1200ms later — and cleanup cancels the save that the previous keystroke scheduled. The result is a debounce, with no library and no subscription to manage.
readonly draft = signal('');
private readonly autosave = effect((onCleanup) => {
const text = this.draft();
if (!text) return;
const timer = setTimeout(() => save(text), 1200);
// Every keystroke re-runs the effect, and cleanup cancels the previous
// pending save. Only the final pause actually writes. That is debouncing,
// with no library and no subscription to manage.
onCleanup(() => clearTimeout(timer));
});Draft autosave
Goal: type at least 12 characters, then stop typing and let it save.
Key takeaways
- ✓
effect(fn)runs an action when its dependencies change — its initial execution is scheduled, then it reacts to dependency changes. - ✓Effects are scheduled and coalesced: many writes in a tick produce one run with the final value.
- ✓
onCleanupruns before every subsequent run and on destroy — the natural home for timers, sockets and listeners. - ✓
untracked()reads a signal without depending on it: "don't wake me for this". - ✓Use computed for pure derivation and linkedSignal for derived state with overrides. Justify signal writes from effects by their purpose.