Angular effect() vs computed(): When Each Fires, Cleanup, untracked() [2026]
computed() and effect() are both signal consumers — they read producers and react to changes. They differ on when they run, what they return, and how they clean up. Mixing them up causes most of the everyday signal bugs: "my effect runs twice," "my computed never updates," "why does this leak?" This lesson walks the exact behavior of both.
This is lesson 3.2, building on lesson 3.1's introduction to the signal graph.
The core distinction in one table #
| Aspect | computed() |
effect() |
|---|---|---|
| Returns | A read-only Signal |
A handle (rarely used) |
| When runs | Lazily — only when someone reads it | Eagerly — every time tracked signals change |
| Re-evaluates on change? | Marks dirty; runs on next read | Runs immediately (microtask later) |
| Side effects allowed? | No — must be pure | Yes — the whole point |
| Cached value? | Yes — between reads | No — runs every time |
| Cleanup | Not needed (no side effects) | Optional cleanup function |
| Where you can create it | Anywhere (any injection context) | Injection context only |
Use computed() for derived data. Use effect() for side effects driven by data.
When effect() runs — the timing rule #
Angular schedules an effect to re-run on the next microtask after any of its dependencies change. Multiple changes in the same synchronous block are batched into a single re-run.
import { Component, signal, effect } from '@angular/core';
@Component({...})
export class FooComponent {
a = signal(1);
b = signal(2);
constructor() {
effect(() => {
console.log(`a=${this.a()}, b=${this.b()}`);
});
// All three writes batch into ONE effect re-run
this.a.set(10);
this.b.set(20);
this.a.set(30);
}
}
The console logs the initial state, then once after the microtask with a=30, b=20. Two intermediate states never appear — Angular collapses them.
If you need synchronous reaction (extremely rare), effect() accepts an allowSignalWrites: true option that schedules differently, but you should almost never need this.
effect() cleanup — the onCleanup callback #
When an effect runs, it can register a cleanup function that runs BEFORE the next run AND when the component is destroyed:
effect((onCleanup) => {
const handle = setInterval(() => console.log('tick'), 1000);
onCleanup(() => clearInterval(handle));
});
The pattern:
- Run the effect — set up the side effect (interval, listener, timer, etc.)
- Register a cleanup —
onCleanup(...) - On the next run, the previous cleanup fires first, then the body runs again
- On destroy, the last cleanup fires (and the effect never runs again)
This matches React's useEffect model but with automatic dependency tracking — no dependency array required.
effect() AND signal writes — the dangerous combo #
Writing to a signal inside an effect that ALSO reads it creates a cycle. Angular throws by default:
const count = signal(0);
// ❌ Throws — effect reads count AND writes count
effect(() => {
console.log(count());
count.update(n => n + 1); // triggers self-re-run forever
});
Two ways to break the cycle:
// Option 1: use untracked() to read without subscribing
effect(() => {
const c = untracked(count);
count.set(c + 1);
// Now this effect has NO dependencies — runs once on creation
});
// Option 2: opt in to writes with the flag
effect(() => {
count.update(n => n + 1);
}, { allowSignalWrites: true });
Option 1 is the cleaner approach for most cases. Option 2 is for advanced patterns where you genuinely need the cycle (rare).
computed() purity — the rule #
The function you pass to computed() MUST be pure:
// ✅ Pure — same inputs → same output, no side effects
const tax = computed(() => price() * 0.07);
// ❌ Not pure — has a side effect (console.log)
const tax = computed(() => {
console.log('computing tax'); // side effect — discouraged but not blocked
return price() * 0.07;
});
// ❌ Not pure — reads outside the signal system
const time = computed(() => Date.now()); // returns different value on each read despite no signal change
The purity rule exists because Angular only re-runs computed() when a tracked signal changes — if your function depends on external state (current time, Math.random, DOM state), the cache returns stale values. For those cases, use signal() and update it manually, or use effect() to bridge external state into the signal world.
computed() equality and memoization #
A computed() re-runs when a dependency changes, but its NEW value is then compared to the OLD value via Object.is() (default) before notifying its own consumers. If the new value equals the old, downstream consumers do not re-run.
const a = signal(1);
const b = signal(2);
const sum = computed(() => a() + b());
let effectRuns = 0;
effect(() => { sum(); effectRuns++; });
// effectRuns === 1 (initial)
a.set(2);
b.set(1);
// sum is still 3 — Object.is(3, 3) is true
// effect does NOT re-run
// effectRuns === 1 still
This means a computed() that returns the same primitive after dependencies change does not propagate the "change" downstream. For objects, you typically replace the reference each time, so Object.is() correctly detects them as different. If you want value-based equality on objects:
const user = computed(
() => ({ name: firstName() + ' ' + lastName() }),
{ equal: (a, b) => a.name === b.name },
);
When to use which — three real cases #
1. Derive a value for the template → computed() #
// ✅ Right
fullName = computed(() => this.first() + ' ' + this.last());
// ❌ Wrong — effect is overkill, no template binding
fullName = signal('');
constructor() {
effect(() => this.fullName.set(this.first() + ' ' + this.last()));
}
The computed version is one line, lazily evaluated, automatically memoized. The effect version is three lines, eagerly evaluated, and writes to an intermediate signal — pointless.
2. Sync state to external system → effect() #
// ✅ Right — side effect of writing to localStorage
constructor() {
effect(() => localStorage.setItem('theme', this.theme()));
}
// ❌ Wrong — computed cannot have side effects
storedTheme = computed(() => { localStorage.setItem('theme', this.theme()); return this.theme(); });
Writing to localStorage is a side effect; that is exactly what effect() is for.
3. Conditional re-rendering → computed() + template #
// ✅ Right — the template re-renders when the computed changes
@Component({
template: `
@if (isAdmin()) {
<admin-panel />
}
`,
})
export class HeaderComponent {
user = inject(AuthService).user;
isAdmin = computed(() => this.user()?.role === 'admin');
}
The computed is read by the template — @if re-evaluates when isAdmin() changes. No effect needed; the template IS the consumer.
Effects that need an Injector outside a constructor #
The one-line summary of how to call effect() from outside an injection context:
export class UserService {
private injector = inject(Injector);
watchUser() {
runInInjectionContext(this.injector, () => {
effect(() => console.log(this.user()));
});
}
}
Capture the injector at construction; pass it whenever you need to call effect() (or inject()) elsewhere. This is the official escape hatch for the "effect outside injection context" error.
untracked() patterns #
Three common reasons to wrap a read in untracked():
Snapshot for logging #
effect(() => {
console.log(`User ${this.userId()} changed to ${untracked(this.user)}`);
// Effect re-runs only when userId changes, not when user changes
});
Avoid feedback loops #
effect(() => {
const next = compute(this.input());
this.output.set(next); // would loop if `this.input` was tracked here
}, { allowSignalWrites: true });
Read configuration without re-running #
effect(() => {
const data = this.data();
const config = untracked(this.config); // config rarely changes; don't re-run when it does
this.render(data, config);
});
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Effect runs in an infinite loop | Effect writes to a signal it also reads | Use untracked() for the read, or set allowSignalWrites: true |
| Effect never runs | Created outside an injection context — silently failed | Move to a constructor, or wrap with runInInjectionContext |
| Computed value is stale | Function reads non-signal state (Date.now, DOM, etc.) | Use signal() for the external state, or use effect() instead |
| Computed re-runs on every read | Equality check fails (returning new object each call) | Provide custom equal: or ensure stable returns |
| Cleanup not firing | Returned a value from the effect function instead of calling onCleanup | Use the onCleanup parameter, not a return value |
What's next #
Lesson 3.3 covers model() — the two-way binding primitive that wraps an input+output pair into a single signal. Lesson 3.4 walks debounced(). Lessons 3.5 and 3.6 dive into zoneless change detection. Lesson 3.7 is the signal-vs-observable decision tree. Lesson 3.8 closes Module 3 with linkedSignal().
Try it yourself #
A component that uses computed for the template and effect for persistence:
import { Component, signal, computed, effect, inject, DestroyRef } from '@angular/core';
@Component({
selector: 'app-prefs',
template: `
<label>
<input type="checkbox" [checked]="darkMode()" (change)="toggle()" />
Dark mode
</label>
<p>Status: {{ status() }}</p>
`,
})
export class PrefsComponent {
darkMode = signal(localStorage.getItem('darkMode') === 'true');
status = computed(() => this.darkMode() ? '🌙 Dark mode is on' : '☀️ Light mode is on');
constructor() {
// Side effect — persist to localStorage on every change
effect(() => localStorage.setItem('darkMode', String(this.darkMode())));
// Side effect — toggle a body class
effect(() => document.body.classList.toggle('dark', this.darkMode()));
}
toggle() { this.darkMode.update(v => !v); }
}
One signal (the source of truth), one computed (for the template), two effects (for persistence and DOM side effects). Each effect runs once when darkMode() changes, with cleanup automatic on destroy.
find_examplesUse resource() or httpResource() (lesson 6.3), not effect(). The resource APIs were built for exactly this pattern: a signal-driven async operation with built-in cancellation, loading states, and error handling. effect() would work but you’d reimplement the cancellation logic yourself — and miss that the previous request needs to be aborted when the input changes. resource() handles that for you.Lesson 3.3 picks up model() — the two-way binding primitive.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


