Angular effect() vs computed(): When Each Fires, Cleanup, untracked() [2026]

Link copied
Angular effect() vs computed(): When Each Fires, Cleanup, untracked() [2026]

Angular effect() vs computed(): When Each Fires, Cleanup, untracked() [2026]

Angular Tutorial Module 3: Signals Lesson 3.2

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:

  1. Run the effect — set up the side effect (interval, listener, timer, etc.)
  2. Register a cleanup — onCleanup(...)
  3. On the next run, the previous cleanup fires first, then the body runs again
  4. 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.

YouI want to call an HTTP endpoint whenever a signal changes. Effect or something else?
Claude · used 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

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *