skip to content

In Angular, what do an effect's onCleanup callback, the manualCleanup option and EffectRef.destroy() each control?

level: middleimportance: should knowfreq 38%

answer

  1. first parameter of the callback
  2. before the next run, and on destroy
  3. opt out of DestroyRef
  4. you own the teardown then

basics

~20 s

onCleanup registers a function that runs before the effect's next run and when it is destroyed. manualCleanup: true stops Angular from registering the effect with the injector's DestroyRef. EffectRef.destroy() stops the effect and runs its pending cleanup.

solid answer

~50 s

The callback passed to `effect()` receives `onCleanup` as its first parameter. A function registered with it runs **before the next run** of the effect and **when the effect is destroyed**, which is how you cancel a timer, abort a request or remove a listener that the previous run started. By default the effect is also registered with the injector's `DestroyRef`, so it is destroyed with its component, or with its injector for a root effect. `manualCleanup: true` skips that registration, so for a root effect, or one created with an injector from elsewhere, it lives until you call `destroy()` on the `EffectRef` that `effect()` returns; the docs warn you must actually do so. An effect attached to a component's view is still torn down when that view is destroyed. `destroy()` removes the effect from future runs and runs its registered cleanups. Most code needs only `onCleanup`.

code

ts · 30 lines
ts
import {Component, EffectRef, Injector, effect, inject, signal} from '@angular/core';

@Component({
  selector: 'app-theme-sync',
  template: `<button (click)="toggleSync()">{{ syncing() ? 'Stop' : 'Start' }} syncing</button>`,
})
export class ThemeSync {
  readonly theme = signal<'light' | 'dark'>('light');
  readonly syncing = signal(false);
  private readonly injector = inject(Injector);
  private ref: EffectRef | null = null;

  toggleSync() {
    if (this.ref) {
      this.ref.destroy(); // runs the pending cleanup, stops future runs
      this.ref = null;
      this.syncing.set(false);
      return;
    }
    this.ref = effect(
      (onCleanup) => {
        const theme = this.theme();
        const id = setTimeout(() => localStorage.setItem('app-theme', theme), 300);
        onCleanup(() => clearTimeout(id));
      },
      {injector: this.injector},
    );
    this.syncing.set(true);
  }
}

go deeper

for a junior

Recall that the effect callback's first parameter, onCleanup, registers teardown for timers or requests the run started.

for a middle

Explain that cleanups run before the next run and on destroy, that effects are tied to DestroyRef by default, and what manualCleanup changes.

for a senior

Show how you manage effects switched on and off at runtime with EffectRef, and how you would catch a manualCleanup leak in review or tests.

for a principal

Set a team convention for effect lifetimes: when defaults suffice, when manual lifetimes are justified, and who owns their teardown.

## Three controls, three questions An Angular effect has a **per-run** lifetime (what the last run started) and an **overall** lifetime (how long the effect exists). The three APIs split cleanly along that line: | API | Scope | Answers | |---|---|---| | `onCleanup(fn)` | per run | "What must be undone before the next run, or at the end?" | | `manualCleanup: true` | overall | "Should Angular destroy this effect for me?" | | `EffectRef.destroy()` | overall | "Stop this effect now." | ## `onCleanup`: undo what the last run started The effect callback's first parameter is a registration function: ```ts effect((onCleanup) => { const theme = this.theme(); const id = setTimeout(() => localStorage.setItem('app-theme', theme), 300); onCleanup(() => clearTimeout(id)); }); ``` Registered functions run: 1. **before the next run** of the effect, when a dependency changed; 2. **when the effect is destroyed**, whether automatically or via `destroy()`. Details worth knowing: - You may register **several** cleanups in one run; all of them run, and the list is cleared afterwards. - Cleanups run with **no active reactive consumer**, so signal reads inside them are not tracked. - An error thrown by a cleanup still counts as cleanup done, but it surfaces as an error in the effect's run. The example above is a debounce: rapid theme toggles cancel the previous pending write, so only the last value reaches storage. The same shape cancels a `fetch` through an `AbortController`, removes an event listener, or disconnects an observer. ## Automatic destruction by default When you create an effect, Angular (unless told otherwise) registers it with the injector's **`DestroyRef`**: - a **view effect** is destroyed when its component or view is destroyed; - a **root effect** is destroyed when its injector is destroyed, which for a root service means when the application is destroyed. On destruction the effect is unsubscribed from its signals, removed from its scheduler, and its registered cleanups run. For most effects this default is exactly right, and `onCleanup` is the only teardown code you write. ## `manualCleanup: true`: you own the lifetime ```ts const ref = effect(() => sync(this.theme()), { injector: this.injector, manualCleanup: true, }); // later ref.destroy(); ``` With `manualCleanup: true`, Angular does **not** register the effect with the injector's `DestroyRef`. For a **root effect** that means nothing ends it except `destroy()`. A **view effect** is different: Angular also destroys the effects attached to a view when that view is destroyed, so an effect created from a component's own injector still ends with the component. Use the option when the effect's lifetime is decided by something other than the injector, for example a feature that is switched on and off at runtime, or an effect created in a long-lived injector that should end earlier. Two cautions: - The documentation is explicit: be careful to **actually destroy** such effects when they are no longer needed. Forgetting is a leak that keeps running side effects. - `manualCleanup` does not remove the need for an injection context: outside one you still pass `injector`. ## `EffectRef.destroy()` `effect()` returns an **`EffectRef`**, stable API since v20, with one method, `destroy()`. It shuts the effect down, removing it from any upcoming scheduled runs, and runs its pending cleanups. It works on any effect, not just manual ones; with automatic cleanup it simply ends the effect earlier than its injector would. ## `afterRenderEffect` follows the same pattern `afterRenderEffect()` passes a cleanup registration function to each phase callback, runs those cleanups before the phase re-runs or when the effect is destroyed, accepts `manualCleanup` in its options, and returns an `AfterRenderRef` with `destroy()`. ## Choosing - Starting a timer, request, subscription or listener inside an effect: **`onCleanup`**, always. - An effect that should live exactly as long as its component or service: **defaults**, nothing else. - An effect switched on and off at runtime: keep the `EffectRef` and call **`destroy()`**; add **`manualCleanup: true`** only if it must outlive or ignore its injector's destruction.

  • In Angular, does an effect's onCleanup run after the effect's final run when the component is destroyed?
    Yes. Destroying the effect, automatically with its component or through `destroy()`, runs the cleanups registered by its last run. That is what makes a timer or request started by the final run safe to leave to `onCleanup` rather than to `ngOnDestroy`.
  • In Angular, if you create a root effect with manualCleanup: true and never call destroy(), what happens?
    Nothing ever ends it: it keeps its signal dependencies and runs its side effect whenever they change, even after the feature that created it is gone. The docs warn about exactly this; keep the `EffectRef` and destroy it when the feature ends.

saying these in an interview costs you the question

  • onCleanup runs only when the component is destroyed, not between runs.
  • Effects must be destroyed in ngOnDestroy or they leak.
  • manualCleanup: true means onCleanup callbacks never run.
  • You can return a cleanup function from the effect callback instead of onCleanup.
  • manualCleanup removes the need for an injection context.