skip to content

In Angular, how do you add custom event syntax such as (input.debounce.300) with EVENT_MANAGER_PLUGINS, and how does EventManager pick the plugin?

level: seniorimportance: nice to knowfreq 18%

answer

  1. extend an abstract class
  2. supports plus addEventListener
  3. multi provider, consulted in reverse
  4. return a cleanup function

basics

~10 s

Extend EventManagerPlugin, implement supports() and addEventListener() returning a cleanup function, and provide it under EVENT_MANAGER_PLUGINS with multi: true. EventManager asks custom plugins first and the catch-all DOM plugin last.

solid answer

~40 s

`EventManager` resolves every event name through the plugins provided under the `EVENT_MANAGER_PLUGINS` multi token. A custom plugin extends `EventManagerPlugin` and implements `supports(eventName)`, returning `true` for names it owns, and `addEventListener(element, eventName, handler)`, which attaches whatever native listeners it needs, calls `handler(event)` when appropriate, and returns a function that removes everything. `EventManager` reverses the provided list, so app plugins registered after the built-ins are asked first, then `KeyEventsPlugin`, with `DomEventsPlugin` moved to the end because it accepts any name. The first plugin that supports a name wins, and the choice is cached per name. The handler it receives is Angular's wrapped listener, so calling it later still marks the view dirty.

code

ts · 16 lines
ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-product-filter',
  template: `
    <input
      #box
      aria-label="Filter products"
      (input.debounce.300)="term.set(box.value)"
    />
    <p>Filtering by: {{ term() }}</p>
  `,
})
export class ProductFilter {
  term = signal('');
}

go deeper

for a junior

Recall that Angular event names are resolved by plugins and that the DOM plugin handles ordinary events.

for a middle

Explain supports() and addEventListener(), the multi provider, and why the DOM plugin always goes last.

for a senior

Write a correct plugin: precise supports(), full cleanup, invoking Angular's wrapped handler, and global targets as the element.

for a principal

Judge whether app-wide event syntax is worth its invisibility compared with a directive or an RxJS pipeline the team can discover.

## Where event names are resolved Every native event binding in a template or host listener (as opposed to a binding to a component output) ends up as a call to the renderer's `listen()`, which delegates to `EventManager.addEventListener(element, eventName, handler)`. `EventManager` does not know any event names itself. It holds a list of **plugins** injected through the multi token `EVENT_MANAGER_PLUGINS` from `@angular/platform-browser`, and asks them in turn. The browser platform registers two built-ins: | Plugin | `supports()` returns true for | What it does | |---|---|---| | `KeyEventsPlugin` | `keydown.*` / `keyup.*` names it can parse | Listens to the base key event and filters by key and modifiers | | `DomEventsPlugin` | every name | Calls `element.addEventListener(name, handler)` verbatim | ## How a plugin is chosen In its constructor `EventManager`: 1. Sets itself as `manager` on every plugin. 2. Takes all plugins **except** `DomEventsPlugin` and **reverses** their order. 3. Appends `DomEventsPlugin` at the end, because it supports everything and would otherwise shadow the rest. With `bootstrapApplication`, the app's providers come after the browser providers, so after the reversal a custom plugin is asked **before** `KeyEventsPlugin`. For each name, the first plugin whose `supports()` returns `true` is used, and the result is cached in a map keyed by the event name. If nothing supports a name, `EventManager` throws a runtime error, although in practice `DomEventsPlugin` always matches. ## Writing a plugin ```ts import { Injectable } from '@angular/core'; import { EventManagerPlugin } from '@angular/platform-browser'; @Injectable() export class DebounceEventPlugin extends EventManagerPlugin { constructor() { super(document); } override supports(eventName: string): boolean { return /^\w+\.debounce(\.\d+)?$/.test(eventName); } override addEventListener(element: HTMLElement, eventName: string, handler: Function): Function { const [base, , ms = '300'] = eventName.split('.'); let timer: ReturnType<typeof setTimeout> | undefined; const listener = (event: Event) => { clearTimeout(timer); timer = setTimeout(() => handler(event), Number(ms)); }; element.addEventListener(base, listener); return () => { clearTimeout(timer); element.removeEventListener(base, listener); }; } } ``` Rules that make a plugin correct: - **Be precise in `supports()`.** A loose test claims names meant for other plugins, since custom plugins are asked first. Returning `false` for anything you cannot parse lets the name fall through, as `KeyEventsPlugin` does. - **Return a real cleanup function.** Angular stores it with the view and calls it when the view is destroyed. Forgetting a pending timer or a second listener here is the typical leak. - **Call the `handler` Angular gave you.** It is the wrapped listener, so invoking it, even later from a timer, marks the declaring view and its ancestors dirty and, in a zoneless app, schedules change detection. Calling a different function would bypass that. - **The plugin sees the global target, too.** For `(window:resize.debounce)` the `element` argument is `window`. ## Registering it ```ts bootstrapApplication(App, { providers: [{ provide: EVENT_MANAGER_PLUGINS, useClass: DebounceEventPlugin, multi: true }], }); ``` The `multi: true` is essential. The built-in plugins are multi providers in the same injector, so a plain provider for the same token does not join the list; in development Angular throws `Cannot mix multi providers and regular providers`. ## The built-in key plugin as a model `KeyEventsPlugin` follows exactly these rules and is worth reading before writing your own: - Its `supports()` parses the name and returns `false` for anything that is not `keydown` or `keyup` plus a recognisable key and modifiers, so unknown words fall through to the DOM plugin. - Its `addEventListener()` attaches one native listener for the base event, calls the Angular handler only when the key string matches, and returns the removal function it got from the DOM adapter. - It registers the native listener outside the Angular zone and re-enters the zone only for a match, which keeps unmatched keystrokes from triggering change detection in zone.js apps. A custom plugin that wraps a gesture library would follow the same shape: parse, attach once, forward to the handler, tear down completely. ## When a plugin is the right tool A plugin changes the meaning of event names **application-wide**, so it suits cross-cutting, declarative syntax (debounced or throttled variants, gesture names backed by a library) that many templates will use. For one component's needs, a directive with a host listener or an RxJS pipeline is easier to find and test. Because the syntax is invisible at the use site, document it: a reader seeing `(input.debounce.300)` needs to know it is not a browser feature.

  • What happens if you provide an Angular event plugin under EVENT_MANAGER_PLUGINS without multi: true?
    The browser platform already registers `DomEventsPlugin` and `KeyEventsPlugin` as multi providers of that token in the same injector, so a regular provider cannot join the list. In development Angular throws `Cannot mix multi providers and regular providers` when the provider is registered. The plugin is never quietly added.
  • Why must an Angular event plugin call the handler it was given rather than its own callback?
    The handler is Angular's wrapped listener. Invoking it marks the declaring view and its ancestors dirty, notifies the scheduler in a zoneless app, applies the return-false `preventDefault()` rule and routes errors to Angular's error handling. A plugin that bypasses it produces handlers that run but may not update `OnPush` views.

saying these in an interview costs you the question

  • Custom plugins are consulted after the built-in DOM events plugin
  • Providing the plugin without multi: true still adds it to the list
  • addEventListener in a plugin does not need to return anything
  • Calling the handler from a timer bypasses change detection
  • A plugin is only active in the component that imports it