← Back to Index

Lesson 2

Effects, Lifecycle & Untracked

The Core Idea

Mental model: An effect is a side-effect function that Angular re-runs whenever its signal dependencies change. It's the signal world's equivalent of tap() inside a subscription - the place where you interact with non-reactive APIs (logging, localStorage, DOM, third-party libraries). It is not for deriving state.
Not to be confused with NgRx Effects. Angular's effect() and NgRx's createEffect() share the name "effect" and the concept of "do something in response to a change," but they operate at different layers. Angular's effect() is a framework primitive - it reacts to signal value changes and runs locally within a component or service lifecycle. NgRx Effects react to dispatched actions, orchestrate async workflows (API calls, navigation), and typically dispatch further actions back into the store. Think of Angular's effect() as the low-level "sync to the outside world" tool, and NgRx Effects as a higher-level architectural pattern for managing complex async flows. We'll revisit this distinction in the SignalStore lessons, where rxMethod bridges the two models.

In the RxJS world, you'd subscribe to an observable and do something with the value - log it, save it to localStorage, update a chart. In the signals world, effect() fills that role. But with a critical difference: effects are the last tool you should reach for, not the first.

Creating an Effect

import { signal, effect } from '@angular/core';

const count = signal(0);

effect(() => {
  console.log(`The count is: ${count()}`);
});
// Logs immediately: "The count is: 0"

count.set(5);
// Logs again: "The count is: 5"

Key behaviours:

How Does Angular Know Which Signals to Watch?

There's no explicit registration - no "subscribe to this signal" call. The link between a signal and an effect is created purely by reading the signal inside the effect's function body.

The mechanism:

  1. Angular starts executing the effect function and activates a reactive context (a tracking scope).
  2. Every signal that gets read (you call mySignal()) during that synchronous execution registers itself as a dependency.
  3. When any registered signal changes value, the effect is scheduled to re-run.
  4. On re-run, the dependency set is rebuilt from scratch (dynamic tracking).
const name = signal('Alice');
const age = signal(30);
const theme = signal('dark');

effect(() => {
  console.log(`${name()} is ${age()}`);
  // name and age are dependencies (they were called)
  // theme is NOT a dependency (never called in here)
});

Changing theme will never trigger this effect. Changing name or age will. And because tracking is dynamic, conditional branches change the dependency set:

effect(() => {
  if (showDetails()) {
    console.log(name(), age()); // showDetails + name + age tracked
  } else {
    console.log(name());        // only showDetails + name tracked
  }
});

If showDetails() returns false, changes to age won't trigger the effect until showDetails flips back to true. This is the same tracking mechanism that powers computed(), and it's the reason untracked() works (covered later in this lesson) - it reads a signal's value without triggering the registration step.

Key insight: calling a signal = subscribing to it. Not calling it = not subscribed. The effect's dependency graph is rebuilt on every execution, not declared up front.

The RxJS Comparison

// RxJS way - subscribe + tap for side effects
this.count$.pipe(
  tap(count => console.log(`The count is: ${count}`))
).subscribe();
// Don't forget to unsubscribe!

// Signal way - effect handles it all
effect(() => {
  console.log(`The count is: ${this.count()}`);
});
// No subscription. No teardown. Cleaned up automatically.

The effect is destroyed automatically when the component/directive that created it is destroyed. No takeUntilDestroyed(), no ngOnDestroy, no subscription management.

When to Use Effects (and When Not To)

This is the most important section of this lesson. The Angular team is explicit: effects should be your last resort. The official docs say "there are no situations where effect is good, only situations where it is appropriate."

Appropriate uses

Use caseExample
Logging / analyticsSend signal values to a logging service or analytics platform
Syncing to external storageWrite to localStorage, sessionStorage, cookies
Custom DOM behaviourUpdate a canvas, integrate a charting library, manipulate elements that templates can't express
Third-party library integrationPush data to a non-Angular library that has its own imperative API

What NOT to use effects for

Never use effects to propagate state changes. If you find yourself writing effect(() => { someOtherSignal.set(derivedValue()) }), stop. You're creating a chain reaction that Angular can't optimise, risks circular updates, and may trigger ExpressionChangedAfterItHasBeenCheckedError. Use computed() or linkedSignal() (covered in Lesson 3) instead. linkedSignal() is a writable signal that automatically resets its value based on a reactive source - like a computed() that you can also .set() manually when the user overrides it.
// WRONG - using effect to propagate state
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = signal('');

effect(() => {
  fullName.set(`${firstName()} ${lastName()}`); // Don't do this!
});

// RIGHT - use computed for derived state
const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => `${firstName()} ${lastName()}`);

The heuristic: If the output of your effect is "set another signal" or "change some state", you almost certainly want computed() or linkedSignal() instead. Effects are for crossing the boundary out of the reactive system - into the DOM, into localStorage, into a third-party API, into a log.

Injection Context

Effects must be created within an injection context - meaning inside a constructor, a field initialiser, or somewhere you have access to inject(). This is because effects need Angular's DI to manage their lifecycle (knowing when to destroy them). Using our Policy Dashboard as the example:

@Component({ /* ... */ })
export class PolicyDashboard {
  private readonly selectedPolicy = signal<Policy | null>(null);

  // Option 1: field initialiser (works because it runs in injection context)
  private readonly logEffect = effect(() => {
    console.log('Selected policy:', this.selectedPolicy()?.id);
  });

  // Option 2: constructor
  constructor() {
    effect(() => {
      console.log('Selected policy:', this.selectedPolicy()?.id);
    });
  }
}

If you need to create an effect later (outside the constructor), pass an Injector:

@Component({ /* ... */ })
export class PolicyDashboard {
  private readonly injector = inject(Injector);
  private readonly selectedPolicy = signal<Policy | null>(null);

  startLogging(): void {
    effect(() => {
      console.log('Selected policy:', this.selectedPolicy()?.id);
    }, { injector: this.injector });
  }
}

The RxJS equivalent: This is like how takeUntilDestroyed() needs a DestroyRef when used outside the constructor. Same idea - Angular needs to know the lifecycle scope.

Ad

Effect Cleanup

Effects often start operations that need cancelling - timers, network requests, event listeners. The onCleanup callback handles this:

effect((onCleanup) => {
  const user = currentUser();

  const timer = setTimeout(() => {
    console.log(`1 second ago, the user became ${user}`);
  }, 1000);

  // Called before the next run, or when the effect is destroyed
  onCleanup(() => {
    clearTimeout(timer);
  });
});

onCleanup is called in two situations:

  1. Before the effect re-runs - a dependency changed, so the previous operation needs cancelling before the new one starts.
  2. When the effect is destroyed - the component is being torn down.

Effect Cleanup Lifecycle

Signal changes onCleanup fires Effect re-runs clearTimeout() new setTimeout() 1 2 3 currentUser changes value Previous timer cancelled New timer started

The cleanup from the previous run always fires before the effect executes again. This prevents resource leaks (dangling timers, stale listeners, orphaned requests).

The RxJS Comparison

// RxJS way - switchMap cancels the previous inner observable
this.currentUser$.pipe(
  switchMap(user => timer(1000).pipe(
    tap(() => console.log(`1 second ago, the user became ${user}`))
  )),
  takeUntilDestroyed()
).subscribe();

// Signal way - onCleanup cancels the previous timeout
effect((onCleanup) => {
  const user = currentUser();
  const timer = setTimeout(() => {
    console.log(`1 second ago, the user became ${user}`);
  }, 1000);
  onCleanup(() => clearTimeout(timer));
});

The switchMap pattern and the onCleanup pattern are solving the same problem: cancel the previous operation when a new one starts. The signal version is more explicit about what's being cleaned up.

Untracked: Reading Without Dependency

Sometimes you need to read a signal inside an effect (or computed) without creating a dependency on it. untracked() does this:

import { signal, effect, untracked } from '@angular/core';

const currentUser = signal('Alice');
const counter = signal(0);

effect(() => {
  // This effect should only re-run when currentUser changes.
  // counter is read but should NOT trigger a re-run.
  console.log(`User set to ${currentUser()} and the counter is ${untracked(counter)}`);
});

Without untracked, this effect would re-run every time either signal changes. With it, only changes to currentUser trigger the effect - counter is read but not tracked.

When to use untracked: When you need a signal's current value for context but don't want changes to that signal to re-trigger the effect. Common examples: reading a configuration value, accessing an ID for a log message, or calling a service method that happens to read signals internally.

You can also wrap an entire block:

effect(() => {
  const user = currentUser(); // tracked - triggers re-run

  untracked(() => {
    // Nothing read in here creates a dependency
    this.loggingService.log(`User set to ${user}`);
    // Even if loggingService internally reads signals, they won't be tracked
  });
});

The RxJS Comparison

There's no direct RxJS equivalent because RxJS subscriptions don't have "tracked reads." The closest analogy is using withLatestFrom() vs combineLatest():

// combineLatest - re-emits when EITHER changes (like two tracked reads)
combineLatest([currentUser$, counter$]).pipe(
  tap(([user, count]) => console.log(`${user}, ${count}`))
);

// withLatestFrom - only re-emits when currentUser$ emits,
// grabs counter's latest value passively (like untracked)
currentUser$.pipe(
  withLatestFrom(counter$),
  tap(([user, count]) => console.log(`${user}, ${count}`))
);

So untracked(counter) is the signal equivalent of withLatestFrom(counter$) - "give me the value, but don't make me react to it."

Async and Reactive Context

A critical rule: the reactive context is only active for synchronous code. Signal reads after an await or inside a callback are not tracked:

// BROKEN - theme() is read after await, so it won't be tracked
effect(async () => {
  const data = await fetchUserData();
  console.log(`User: ${data.name}, Theme: ${theme()}`); // theme not tracked!
});

// FIXED - read all signals before the await
effect(async () => {
  const currentTheme = theme(); // tracked - read before await
  const data = await fetchUserData();
  console.log(`User: ${data.name}, Theme: ${currentTheme}`);
});

This applies to all reactive contexts - effect(), computed(), templates. The tracking only works synchronously during the initial execution. Anything after an async boundary is invisible to the dependency graph.

Why? Angular's dependency tracking works by intercepting signal reads during execution. After an await, the execution has already "returned" from Angular's perspective - the reactive context is closed. The callback after the await runs in a different microtask with no tracking active. This is a fundamental constraint of the push-invalidation model, not a bug.

View Effects vs Root Effects

Angular distinguishes between two kinds of effect based on where they're created:

View EffectRoot Effect
Created inComponent, directive, or their servicesRoot-provided service (outside the component tree)
RunsBefore its component is checked by CDBefore all components are checked
DestroyedWhen the component is destroyedWhen the application is destroyed

In practice, most effects you write will be view effects. Root effects are for application-wide concerns like syncing to localStorage in a global service.

The real rule: An effect is destroyed when its injector is destroyed. A component-level injector dies with the component. A root-level injector dies with the application. If you provide a service at the component level (in the component's providers array), effects created inside that service share the component's lifecycle - they're destroyed when the component is destroyed. This matters because in well-structured Angular apps, most business logic lives in services injected into components, not in the components themselves.

afterRenderEffect: DOM Side Effects

afterRenderEffect() is not a lifecycle hook - it's a reactive primitive, introduced in Angular 19 alongside the signals system. The distinction matters:

It exists because effect() has a timing problem: it runs before Angular updates the DOM. If your side effect needs to read or manipulate the actual rendered DOM - measure an element, update a canvas, interact with a third-party library that needs a real DOM node - effect() is too early. afterRenderEffect() gives you the reactivity of effect() with the timing guarantee that the DOM is current.

The RxJS Comparison

// Old way: lifecycle hook + manual subscription
ngAfterViewInit() {
  this.chartData$.pipe(
    takeUntilDestroyed(this.destroyRef)
  ).subscribe(data => {
    this.chart.update(data); // DOM manipulation
  });
}

// New way: reactive + DOM-safe, no subscription management
constructor() {
  afterRenderEffect(() => {
    this.chart.update(this.chartData()); // runs after DOM is updated
  });
}

Example: Chart Integration

The example below uses afterNextRender() alongside afterRenderEffect(). Note that afterNextRender() is a different category of thing entirely - it's not reactive (no signal tracking), not a lifecycle hook (no interface to implement), and not part of the signals system. It's a one-shot scheduling utility: "run this callback once after the next render completes, then never again." Think of it as a functional replacement for ngAfterViewInit - same timing, but called in the constructor instead of implemented as an interface method. It pairs naturally with afterRenderEffect() because DOM setup (one-time) and DOM updates (reactive) often go hand in hand.

import { afterRenderEffect, afterNextRender, viewChild, ElementRef } from '@angular/core';

@Component({
  template: `<canvas #canvas></canvas>`
})
export class PolicyChart {
  chartData = input.required<ChartData>();
  canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
  chart!: ChartInstance;

  constructor() {
    // Initial setup - runs once after first render (not reactive)
    afterNextRender({
      write: () => {
        this.chart = initializeChart(this.canvas().nativeElement, this.chartData());
      }
    });

    // Reactive update - re-runs after DOM update whenever chartData changes
    afterRenderEffect(() => {
      this.chart.updateData(this.chartData());
    });
  }
}

afterRenderEffect also supports render phases (earlyRead, write, mixedReadWrite, read) to avoid layout thrashing - but for most cases the simple form above is sufficient.

When to use which:
  • effect() - for non-DOM side effects (logging, storage, analytics)
  • afterRenderEffect() - for DOM manipulation or third-party libraries that need the rendered DOM
  • Neither - for derived state (use computed() or linkedSignal())

Manual Cleanup and Destroying Effects

Effects are normally destroyed automatically with their component. If you need manual control:

const effectRef = effect(() => {
  console.log('Policy:', this.selectedPolicy()?.id);
}, { manualCleanup: true });

// Later, when you're done with it:
effectRef.destroy();

The manualCleanup: true option disables automatic destruction when the component dies. You take responsibility for calling .destroy(). Calling .destroy() stops the effect permanently and triggers its onCleanup callback (if registered). It takes no arguments.

When is this useful? When the effect's lifetime doesn't map 1:1 to a component's lifetime - you need to start and stop it independently:

@Component({ /* ... */ })
export class LivePolicyPanel {
  private readonly injector = inject(Injector);
  private readonly selectedPolicyId = input.required<string>();
  private pollingEffect: EffectRef | null = null;

  // Start polling when user opens the live panel
  openLiveView(): void {
    this.pollingEffect = effect(() => {
      this.policyService.fetchLatest(this.selectedPolicyId());
    }, { manualCleanup: true, injector: this.injector });
  }

  // Stop polling when they close it - component stays alive
  closeLiveView(): void {
    this.pollingEffect?.destroy(); // stops effect, runs onCleanup
    this.pollingEffect = null;
  }
}

Without manualCleanup, the only way to stop this effect would be to destroy the entire component. With it, you get fine-grained control over when the reactive work starts and stops. That said, this is rare - most effects should just live and die with their component.

Quiz

You want to update a chart library whenever your signal-based data changes. Which API do you use?

effect() - it runs when signals change
afterRenderEffect() - it runs after the DOM is updated
computed() - it derives state from signals

You read theme() after an await inside an effect. What happens?

The effect re-runs whenever theme changes
theme is not tracked - changes to it won't re-trigger the effect
Angular throws a runtime error about async reactive contexts

You have effect(() => { totalPrice.set(quantity() * unitPrice()) }). What's wrong?

Nothing - this is the correct way to derive state
It will throw because you can't call .set() inside an effect
It's state propagation via effect - use computed() instead

What does untracked(counter) do inside an effect?

Prevents counter from being modified by the effect
Reads counter's value without making it a dependency of the effect
Marks counter as stale so it recalculates on next read

Try It Yourself

Exercise 1: localStorage Sync Effect

Build an effect that persists the user's selected theme to localStorage whenever it changes. Requirements:

  1. Create a writable signal theme with initial value 'light'
  2. Write an effect that saves the theme value to localStorage under the key 'app-theme'
  3. The effect should NOT re-run when other signals in the component change

Is this an appropriate use of effect(), or should it be a computed()? Why?

import { signal, effect } from '@angular/core';

@Component({ /* ... */ })
export class ThemeManager {
  readonly theme = signal<'light' | 'dark'>('light');

  constructor() {
    effect(() => {
      localStorage.setItem('app-theme', this.theme());
    });
  }

  toggle(): void {
    this.theme.update(t => t === 'light' ? 'dark' : 'light');
  }
}

This is an appropriate use of effect() because localStorage is an external side effect - you're crossing the boundary out of the reactive system. A computed() can't do this because it only derives values, it can't perform side effects. The effect only tracks theme because that's the only signal read in the function body.

Exercise 2: Cleanup Challenge

You're building a live policy feed that polls an API every 5 seconds. When the selected policy changes, the old polling interval should be cancelled and a new one started. Write the effect with proper cleanup:

// Fill in the effect body
const selectedPolicyId = signal('POL-001');

effect((onCleanup) => {
  // TODO: read the selected policy ID
  // TODO: start a setInterval that polls every 5 seconds
  // TODO: register cleanup to cancel the interval
});
const selectedPolicyId = signal('POL-001');

effect((onCleanup) => {
  const policyId = selectedPolicyId(); // tracked dependency

  const intervalId = setInterval(() => {
    console.log(`Polling updates for ${policyId}...`);
    // In reality: this.policyService.fetchLatest(policyId);
  }, 5000);

  onCleanup(() => {
    clearInterval(intervalId); // cancel previous polling
  });
});

When selectedPolicyId changes: (1) onCleanup fires, clearing the old interval, (2) the effect re-runs, reading the new ID and starting a fresh interval. When the component is destroyed, onCleanup fires one final time. No orphaned intervals.

Exercise 3: Spot the Bug

This effect is supposed to log analytics when the user navigates to a new policy, including a timestamp. But something's wrong with the dependency tracking. Find and fix the bug:

const selectedPolicy = signal<Policy | null>(null);
const currentTimestamp = signal(Date.now()); // updated every second elsewhere

effect(() => {
  const policy = selectedPolicy();
  if (policy) {
    analytics.track('policy_viewed', {
      policyId: policy.id,
      viewedAt: currentTimestamp(), // we want the timestamp at view time
    });
  }
});

Hint: how often should this analytics event fire? What's actually triggering it?

effect(() => {
  const policy = selectedPolicy();
  if (policy) {
    analytics.track('policy_viewed', {
      policyId: policy.id,
      viewedAt: untracked(currentTimestamp), // read but don't track
    });
  }
});

The bug: currentTimestamp() is a tracked dependency that updates every second. So the analytics event fires every second while a policy is selected - not just when the user navigates. The fix: wrap it in untracked() so we get the timestamp value without making it a dependency. The effect now only re-runs when selectedPolicy changes.

Your Tangible Win

You now know the three tools in the signal side-effect toolkit: effect() for non-DOM side effects, afterRenderEffect() for DOM work, and untracked() for reading without dependency. More importantly, you know when not to use effects - which is most of the time. If you can express it as computed(), do that instead.

Next lesson, we'll look at linkedSignal() - the writable computed that fills the gap between computed() (read-only) and signal() (no reactive source).

Something unclear? Ask me follow-up questions - I'm your teacher and can clarify anything, adjust examples, or go deeper on any concept.