skip to content

An Angular rating component declares an output named change; why does the parent's (change) handler run twice, once receiving a DOM Event instead of the rating?

level: seniorimportance: should knowfreq 30%

answer

  1. outputs are not DOM events
  2. one binding, two listeners
  3. native change bubbles to the host
  4. rename or alias the output

basics

~20 s

An event binding on a component's element subscribes to its matching output and also adds a native DOM listener. The inner radio's native change bubbles to the host, so the handler runs with the number, then with the Event.

solid answer

~40 s

Angular outputs are not DOM events: `emit()` synchronously calls the listeners bound on that element and nothing bubbles. But when Angular compiles `(change)` on an element it does two things: it subscribes to any matching output of a component or directive there, and it registers a native DOM listener for `change`. The rating template's radio input fires a native `change` that bubbles up to the host element, so one click calls the parent's handler with `3` from the output and then with the `Event`. The type-checker types `$event` from the output, so it cannot see the mismatch. The fix is to rename the output, for example `rated`, or alias its template name; the docs say to avoid output names that collide with DOM events.

code

ts · 18 lines
ts
import { Component, output } from '@angular/core';

@Component({
  selector: 'app-rating-stars',
  template: `
    @for (star of stars; track star) {
      <label>
        <input type="radio" name="stars" (change)="rated.emit(star)" />
        {{ star }}
      </label>
    }
  `,
})
export class RatingStars {
  // Was: readonly change = output<number>();  -> parent got 3, then an Event
  readonly rated = output<number>();
  protected readonly stars = [1, 2, 3, 4, 5];
}

go deeper

for a junior

Remember that component outputs are not DOM events and should never reuse native event names like click or change.

for a middle

Explain that an event binding on an element registers a DOM listener and subscribes to matching outputs, and that outputs never bubble.

for a senior

Diagnose double-fired handlers from a name collision, explain why the type-checker misses it, and fix it by renaming rather than stopPropagation.

for a principal

Set naming conventions and review checks for a shared component library so output names can never collide with DOM events.

## The bug report A rating component was written with radio buttons and an output named `change`: ```ts import { Component, output } from '@angular/core'; @Component({ selector: 'app-rating-stars', template: ` @for (star of stars; track star) { <label> <input type="radio" name="stars" (change)="change.emit(star)" /> {{ star }} </label> } `, }) export class RatingStars { readonly change = output<number>(); protected readonly stars = [1, 2, 3, 4, 5]; } ``` The parent writes `<app-rating-stars (change)="save($event)" />` with `save(stars: number)`. The template type-checks. But at runtime `save` is called **twice** per click: first with `3`, then with an `Event` object, and the second call stores `[object Event]` or throws. ## Two different things with one name To understand it you need two facts about how Angular treats an event binding on a component's element. **1. Angular outputs are not DOM events.** `emit()` does not dispatch anything into the DOM. It calls, synchronously, the listeners registered for that output: the handler bound on *that element* in the parent's template, plus any programmatic subscribers. The value never travels up the DOM tree, so no other ancestor can hear it. **2. An event binding on an element also listens to the DOM.** When Angular compiles `(change)="..."` on an element, it registers a **native DOM listener** for `change` on that element, and additionally subscribes to any **output of a matched component or directive** with that name. It does both, not one or the other. The inner `<input type="radio">` fires a native `change` event, and `change` **bubbles**. In the usual setup a component's template elements are real DOM descendants of its host element, so the native event reaches `<app-rating-stars>`, where the parent's DOM listener is waiting. ## The sequence for one click 1. The user selects star 3; the browser dispatches a native `change` event on the radio input. 2. The inner `(change)` listener on the input runs first and calls `change.emit(3)`. 3. `emit()` synchronously calls the parent's handler: `save(3)`. 4. The native event continues bubbling and reaches the host element. 5. The parent's DOM listener on the host runs the same handler: `save(event)`. ## Why the type-checker did not catch it When a component or directive output **claims** an event name on an element, the template type-checker types `$event` from that output, here `number`, and skips the DOM event typing for it. The runtime DOM listener still exists. So the types say `number` while a `Event` also arrives: a mismatch the compiler cannot see. ## Fixes, best first | Fix | Effect | |---|---| | Rename the output, e.g. `rated` | no collision; the parent's `(rated)` hears only the output | | Keep the class field, alias the template name: `output<number>({ alias: 'rated' })` | same, when the field name must stay | | Call `event.stopPropagation()` in the inner listener | hides the native event, but is fragile and blocks other legitimate listeners | | Filter in the parent (`typeof $event === 'number'`) | treats the symptom; every consumer must remember it | The Angular docs make the first fix a rule: **avoid output names that collide with DOM events** such as `click`, `change`, `input`, `focus` or `select`. ## The general lessons - **Outputs do not bubble.** A grandparent with `(change)` on a wrapping `<div>` still receives the *native* `change` event from the radio, but it never receives the component's output. To reach a distant component, share state through a service or pass the event up explicitly at each level. - **`$event` has two meanings.** On an output it is the emitted payload; on a DOM event it is an `Event`. A colliding name mixes the two at runtime. - **Clicks behave the same way.** An output named `click` on a component whose template contains buttons produces the same double call, with the native click event as the second argument. ## How to catch it in review - Grep component classes for `output` or `@Output` fields named after DOM events. - For shared UI libraries, adopt past-tense or domain names (`rated`, `selectionChanged`, `closed`) so collisions are impossible by convention. - Write one test per output that asserts the handler is called **exactly once** with the expected payload.

  • Would a grandparent listening with (rated) on a wrapping div receive the output?
    No. An output is delivered only to listeners bound on the component's own element, plus programmatic subscribers. It is never dispatched into the DOM, so ancestors cannot hear it. A distant component should share state through a service, or each level should re-emit explicitly.
  • Why not just call event.stopPropagation() on the inner radio input?
    It stops the duplicate, but also hides the native `change` from every legitimate ancestor listener, such as analytics or form-level handlers, and the collision remains for the next contributor. Renaming the output removes the cause instead of suppressing one symptom.

saying these in an interview costs you the question

  • Angular outputs bubble up the DOM like native events.
  • An event binding on a component element listens only to its output, never to the DOM.
  • The template type-checker would flag the Event arriving in a number handler.
  • stopPropagation() inside the component is the proper fix for a colliding name.
  • A grandparent can listen for a child's output on a wrapping element.