Computed & Effects
Building on the signal() primitive, Angular provides two powerful companion APIs: computed() for deriving new values from signals, and effect() for running side effects when signals change. Together, they form a complete reactive system that is synchronous, predictable, and garbage-collected automatically.
computed() — Derived State
A computed signal derives its value from other signals. It is:
- Lazy — only recalculates when its signals change AND when it is read
- Memoized — caches the last value; if dependencies have not changed, returns cached result immediately
- Read-only — consumers cannot call
.set()on it
import { signal, computed } from '@angular/core';
const firstName = signal('Alice');
const lastName = signal('Smith');
// Computed derives a new value from signals
const fullName = computed(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // "Alice Smith"
firstName.set('Bob');
console.log(fullName()); // "Bob Smith" — automatically updatedComputed in Components
import { Component, signal, computed } from '@angular/core';
interface Product {
id: number;
name: string;
price: number;
category: string;
}
@Component({
selector: 'app-product-list',
standalone: true,
template: `
<input
type="text"
[value]="filter()"
(input)="filter.set($event.target.value)"
placeholder="Search..."
/>
<p>Showing {{ filteredProducts().length }} of {{ products().length }} products</p>
<p>Total value: {{ totalValue() | currency }}</p>
@for (product of filteredProducts(); track product.id) {
<div>{{ product.name }} - {{ product.price | currency }}</div>
}
`,
})
export class ProductListComponent {
filter = signal('');
products = signal<Product[]>([
{ id: 1, name: 'Laptop', price: 999, category: 'electronics' },
{ id: 2, name: 'Shirt', price: 29, category: 'clothing' },
{ id: 3, name: 'Headphones', price: 149, category: 'electronics' },
]);
// Computed — recalculates only when filter or products changes
filteredProducts = computed(() => {
const term = this.filter().toLowerCase();
if (!term) return this.products();
return this.products().filter(p =>
p.name.toLowerCase().includes(term) ||
p.category.toLowerCase().includes(term)
);
});
// Computed from another computed — dependency chains work naturally
totalValue = computed(() =>
this.filteredProducts().reduce((sum, p) => sum + p.price, 0)
);
}Computed Is Glitch-Free
Angular's signal graph guarantees glitch-free updates: if multiple signals in a computed's dependencies change simultaneously, the computed recalculates only once with the final values — never with an intermediate inconsistent state.
This is a key advantage over naive RxJS combineLatest patterns:
const a = signal(1); const b = signal(2); // If both a and b change, c recalculates exactly once const c = computed(() => a() + b()); // Batch multiple signal updates — single recalculation (Angular 18+) // In earlier versions Angular automatically batches within the same event handler a.set(10); b.set(20); console.log(c()); // 30 — computed once, not twice
effect() — Reacting to Changes
effect() runs a function whenever any signal read inside it changes. Use it for side effects — things that need to happen when data changes but don't produce a new value:
- Logging / analytics
- Syncing to localStorage
- Calling a non-Angular library
- Updating the document title
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-settings',
standalone: true,
template: `
<label>
<input type="checkbox"
[checked]="darkMode()"
(change)="darkMode.set($event.target.checked)"
/>
Dark Mode
</label>
<label>
Font Size:
<input type="range" min="12" max="24"
[value]="fontSize()"
(input)="fontSize.set(+$event.target.value)"
/>
{{ fontSize() }}px
</label>
`,
})
export class SettingsComponent {
darkMode = signal(false);
fontSize = signal(16);
constructor() {
// Effect runs when darkMode or fontSize changes
effect(() => {
// Auto-tracked dependencies: darkMode() and fontSize()
document.body.classList.toggle('dark', this.darkMode());
document.documentElement.style.setProperty(
'--font-size',
`${this.fontSize()}px`
);
// Persist to localStorage
localStorage.setItem('settings', JSON.stringify({
darkMode: this.darkMode(),
fontSize: this.fontSize(),
}));
});
}
}effect() must be called in a reactive context — inside a component/directive/service constructor, or using runInInjectionContext(). It cannot be called in random functions or lifecycle hooks (except constructor).effect() in Services
import { Injectable, signal, effect, inject } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class AppStateService {
private title = inject(Title);
private router = inject(Router);
currentPage = signal('Home');
unreadCount = signal(0);
constructor() {
// Update browser tab title when page or unread count changes
effect(() => {
const count = this.unreadCount();
const page = this.currentPage();
const prefix = count > 0 ? `(${count}) ` : '';
this.title.setTitle(`${prefix}${page} | MyApp`);
});
// Log navigation for analytics
effect(() => {
console.log(`[Analytics] Page visit: ${this.currentPage()}`);
});
}
}Cleanup in Effects
Effects can return a cleanup function that runs before the next execution or when the effect is destroyed:
effect(() => {
const userId = this.selectedUserId();
if (!userId) return;
// Start polling
const interval = setInterval(() => {
this.loadUserData(userId);
}, 5000);
// Return cleanup function — runs when userId changes or component destroys
return () => clearInterval(interval);
});
// Another example: event listeners
effect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') this.closeModal();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
});Avoiding Cycles — Writing to Signals in Effects
By default, writing to a signal inside an effect() that reads the same signal causes an infinite loop. Angular throws an error. To write to a signal from an effect, use allowSignalWrites: true:
// Without allowSignalWrites — would cause infinite loop
effect(() => {
const value = this.count();
this.log.set(`Count changed to ${value}`); // ERROR if log is also read in this effect
}, { allowSignalWrites: true }); // opt in explicitlyallowSignalWrites sparingly. If you find yourself frequently writing to signals inside effects, consider restructuring your logic using computed() instead.Controlling Effect Lifecycle
import { effect, DestroyRef, inject } from '@angular/core';
@Component({ selector: 'app-live-data' })
export class LiveDataComponent {
data = signal<number[]>([]);
constructor() {
// Effect is automatically cleaned up when the component is destroyed
const effectRef = effect(() => {
console.log('Data changed:', this.data().length);
});
// Manually destroy the effect before component is destroyed
const destroyRef = inject(DestroyRef);
destroyRef.onDestroy(() => effectRef.destroy());
// Or: destroy it based on some condition
// effectRef.destroy(); // stops the effect immediately
}
}untracked() — Opting Out of Tracking
Sometimes you need to read a signal inside a computed() or effect() without making it a tracked dependency. Use untracked():
import { signal, computed, effect, untracked } from '@angular/core';
const counter = signal(0);
const threshold = signal(10);
effect(() => {
const current = counter();
// Read threshold WITHOUT making it a dependency
// Effect only re-runs when counter changes, not when threshold changes
const limit = untracked(() => threshold());
if (current > limit) {
console.log(`Counter ${current} exceeded threshold ${limit}`);
}
});computed vs effect — Decision Guide
Use Case | Use computed() | Use effect() |
|---|---|---|
Derive a display value from state | Yes | No |
Filter/sort/transform data for template | Yes | No |
Save to localStorage | No | Yes |
Update the document title | No | Yes |
Log to analytics | No | Yes |
Sync to a non-Angular library | No | Yes |
Needs to return a value | Yes | No |
Needs to call imperative APIs | No | Yes |
Full Example: Theme System
import { Injectable, signal, computed, effect } from '@angular/core';
type Theme = 'light' | 'dark' | 'system';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private _preference = signal<Theme>(
(localStorage.getItem('theme') as Theme) ?? 'system'
);
readonly preference = this._preference.asReadonly();
// Resolve 'system' to actual theme
readonly resolved = computed<'light' | 'dark'>(() => {
const pref = this._preference();
if (pref !== 'system') return pref;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
});
readonly isDark = computed(() => this.resolved() === 'dark');
constructor() {
// Apply theme to DOM whenever it changes
effect(() => {
const theme = this.resolved();
document.documentElement.setAttribute('data-theme', theme);
document.body.classList.toggle('dark-theme', theme === 'dark');
});
// Persist preference
effect(() => {
localStorage.setItem('theme', this._preference());
});
}
setTheme(theme: Theme): void {
this._preference.set(theme);
}
}Summary
computed() creates a memoized, lazy, read-only signal derived from other signals
Computed signals are glitch-free — they recalculate with final values, never intermediate ones
effect() runs side effects synchronously when its signal dependencies change
Effects must be created in a reactive context (constructor, injection context)
Return a cleanup function from an effect to handle teardown
Use untracked() to read a signal without making it a tracked dependency
Prefer computed() for pure derivations; reserve effect() for imperative side effects