AngularJSComputed & Effects

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

TS
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 updated
Computed in Components

TS
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:

TS
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

TS
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(),
      }));
    });
  }
}
Note
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

TS
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:

TS
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:

TS
// 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 explicitly
Warning
Use allowSignalWrites sparingly. If you find yourself frequently writing to signals inside effects, consider restructuring your logic using computed() instead.
Controlling Effect Lifecycle

TS
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():

TS
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

TS
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