skip to content

An Angular appHighlight directive clears its highlight on outside clicks using Renderer2.listen on document, and memory grows as rows come and go; what leaks and how do you fix it?

level: seniorimportance: should knowfreq 38%

answer

  1. listen returns a function
  2. document outlives the row
  3. closure keeps the directive alive
  4. DestroyRef.onDestroy
  5. or a declarative host listener

basics

~20 s

Renderer2.listen on document registers a listener that outlives each directive, and its closure keeps the destroyed directive and its host element reachable. Keep the returned unlisten function and call it from DestroyRef.onDestroy, or use a declarative document listener that Angular removes for you.

solid answer

~40 s

`Renderer2.listen(target, event, handler)` adds a real listener and returns an **unlisten function**; Angular does not track it for you when the target is `document` or `window`, which outlive every row. Each row's directive instance registers another handler, and each handler's closure references the directive, its `ElementRef` and the detached host node, so destroyed rows stay in memory and every click runs every stale handler. The fix is to store the function and call it on destroy: `inject(DestroyRef).onDestroy(unlisten)`. Better still, declare the listener in the directive's `host` metadata as `'(document:click)': 'onDocumentClick($event)'`; Angular removes host listeners when the directive is destroyed. Verify with a heap snapshot after adding and removing rows, looking for detached elements and directive instances, and with a test that clicks after destroy.

code

ts · 19 lines
ts
import {DestroyRef, Directive, ElementRef, inject, Renderer2, signal} from '@angular/core';

@Directive({
  selector: '[appHighlight]',
  host: {'(mouseenter)': 'on.set(true)', '[class.highlighted]': 'on()'},
})
export class Highlight {
  protected readonly on = signal(false);
  private readonly host = inject(ElementRef<HTMLElement>).nativeElement;

  constructor() {
    const unlisten = inject(Renderer2).listen('document', 'click', (e: MouseEvent) => {
      if (!this.host.contains(e.target as Node)) {
        this.on.set(false);
      }
    });
    inject(DestroyRef).onDestroy(unlisten);
  }
}

go deeper

for a junior

Recall that Renderer2.listen returns a function that removes the listener, and that it must be called when the directive is destroyed.

for a middle

Explain why document listeners outlive directives and how the closure retains destroyed hosts, then fix it with DestroyRef.onDestroy.

for a senior

Prove the leak with heap snapshots, prefer declarative host listeners, and add a review rule for unused listen() results.

for a principal

Build cleanup into shared directive patterns and lint rules so global listeners and observers cannot outlive their owners across the codebase.

## The leaking code The directive highlights its row on hover and should clear the highlight when the user clicks anywhere else: ```ts import {Directive, ElementRef, inject, Renderer2, signal} from '@angular/core'; @Directive({ selector: '[appHighlight]', host: {'(mouseenter)': 'on.set(true)', '[class.highlighted]': 'on()'}, }) export class Highlight { protected readonly on = signal(false); private readonly host = inject(ElementRef<HTMLElement>).nativeElement; constructor() { inject(Renderer2).listen('document', 'click', (e: MouseEvent) => { if (!this.host.contains(e.target as Node)) this.on.set(false); }); } } ``` It is applied to every row of a paginated table. Each page change destroys rows and creates new ones. ## What leaks 1. `Renderer2.listen` adds a genuine DOM listener on `document` and returns a function that removes it. The code throws that function away. 2. `document` lives for the whole session, so the listener is never collected. 3. The handler's **closure** references `this`, the directive instance, which references the `host` element and the `on` signal. 4. When a row is destroyed, Angular removes the element from the DOM and destroys the directive's view, but the document listener still points at both. The **detached element** and the **directive instance** stay reachable. 5. After a few hundred page changes there are hundreds of handlers. Every click runs all of them, each calling `contains` on a node that is no longer in the document. Symptoms: memory that climbs with navigation and never returns to baseline, clicks that get slower over time, and a heap snapshot full of `Detached HTMLTableRowElement` entries retained by an event listener. ## Fix 1: keep and call the unlisten function ```ts constructor() { const unlisten = inject(Renderer2).listen('document', 'click', (e: MouseEvent) => { if (!this.host.contains(e.target as Node)) this.on.set(false); }); inject(DestroyRef).onDestroy(unlisten); } ``` `DestroyRef.onDestroy` registers a callback that runs when the directive's injector is destroyed, which happens when its host element's view is destroyed. It is the current replacement for keeping state around for `ngOnDestroy`. ## Fix 2: let Angular own the listener ```ts host: { '(mouseenter)': 'on.set(true)', '(document:click)': 'onDocumentClick($event)', '[class.highlighted]': 'on()', }, ``` Host listeners, including global targets written as `document:` or `window:`, are registered and removed by Angular together with the directive. There is no function to remember and no way to forget cleanup. This is the preferred shape unless you need to add and remove the listener conditionally at runtime. ## Comparing the options | Approach | Cleanup | When to choose it | |---|---|---| | `Renderer2.listen` with the result ignored | none, leaks | never | | `Renderer2.listen` plus `DestroyRef.onDestroy(unlisten)` | manual, explicit | the listener is attached or detached conditionally | | host `'(document:click)'` | automatic | the listener lives as long as the directive | | `addEventListener` on `nativeElement.ownerDocument` | manual `removeEventListener` with the same function | rarely; same risk as the first row if forgotten | ## Why this is easy to miss - The directive works perfectly in a demo with one row and no navigation; the leak only shows with churn. - `Renderer2` feels "managed" by Angular, so developers assume Angular tracks what it registers. It does not track listeners on global targets for you; that is why `listen` returns the removal function. - Change detection keeps working, so nothing looks broken until memory or click latency is profiled. - The same pattern hides in other imperative setup inside directives: `setInterval`, `ResizeObserver`, `IntersectionObserver`, and third-party widgets. Each needs a matching teardown registered in the same place it was created. ## Verifying the fix - **Heap snapshot**: page through the table 20 times, force garbage collection, and filter the snapshot for detached nodes; the count should return to near zero. - **Test**: create the directive's host in a test, destroy it, dispatch a click on `document`, and assert that the destroyed instance's signal is not touched. - **Review rule**: any `listen(...)` whose return value is unused is a defect; the same goes for observers and timers created in directives without a matching `DestroyRef` registration.

  • Does Angular remove a listener that a directive added with Renderer2.listen to its own host element?
    The DOM listener disappears with the element once nothing else references it, but the directive should not rely on that: the returned unlisten function is the contract, and any global target such as `document` or `window` definitely needs it. Registering it with `DestroyRef.onDestroy` makes the lifetime explicit either way.
  • Why is DestroyRef.onDestroy preferred over ngOnDestroy for this cleanup in current Angular?
    It keeps setup and teardown next to each other in the constructor or a helper function, works in any injection context, and returns a function to unregister the callback. `ngOnDestroy` still works, but it forces the unlisten function into a class field and separates it from the code that created it.

saying these in an interview costs you the question

  • Angular automatically removes every listener added via Renderer2.listen
  • Destroying the host element always frees its document listeners
  • The leak is caused by the signal, not by the listener
  • Using ElementRef instead of Renderer2 removes the need for cleanup
  • Calling unlisten in the constructor is enough