skip to content

A widget calls child.dispatchEvent(new CustomEvent('item-selected')), and a listener on the child fires, but an identical listener on the parent container never does. Why, and what has to change?

level: middleimportance: should knowfreq 60%

answer

  1. check the init dictionary, not the listener
  2. every EventInit boolean starts the same way
  3. native events set it, yours does not
  4. fixed at construction, not at dispatch
  5. a second gate exists inside shadow roots

basics

~10 s

CustomEvent's bubbles option defaults to false, so the event is delivered only to listeners on the dispatch target itself. Construct it as new CustomEvent('item-selected', { bubbles: true }) for ancestors to receive it.

solid answer

~40 s

Every flag in the `EventInit` dictionary defaults to `false`, and that includes `bubbles`. Browser-generated events like `click` are created with `bubbles: true`, so people assume it is the norm; a hand-built `CustomEvent` is not. With `bubbles: false` the event still runs through dispatch normally, but only listeners attached to the dispatch target itself are invoked, so the container's listener is never reached. The fix is at construction: `new CustomEvent('item-selected', { bubbles: true, detail })`. Two related gotchas: the target has to be connected to the document for ancestors to exist in the first place — dispatching on a detached node reaches nothing above it — and if the widget lives inside a shadow root, bubbling alone still stops at the shadow boundary unless the event is also `composed: true`.

go deeper

for a junior

Remember that a hand-built CustomEvent does not bubble unless you ask: the bubbles: true option goes in the constructor's second argument, not in addEventListener.

for a middle

Explain that every EventInit boolean defaults to false, that the flag is frozen at construction, and that the target must already be connected for ancestors to receive anything.

for a senior

Treat the flag as API design: say when a component should broadcast upward versus keep an event target-local, and diagnose a missed event by checking connectedness and shadow boundaries rather than the listener.

for a principal

Own the emission contract across a component library — a documented event name, payload shape and propagation flags per component, plus a shared emit helper so defaults are never relied on silently.

## The default nobody expects `EventInit` has three boolean members — `bubbles`, `cancelable`, `composed` — and every one of them defaults to `false`. `CustomEventInit` extends it with `detail`, defaulting to `null`. So `new CustomEvent('item-selected')` is a non-bubbling, non-cancelable, non-composed event carrying no payload. This surprises people because the events they normally handle come from the browser, and the HTML and UI Events specifications create most of those with `bubbles` set. `click`, `input`, `change`, `keydown` all bubble. A handful of native events deliberately do not — `focus`, `blur`, `load` on an element, `mouseenter` — which is why `focusin`/`focusout` exist as the bubbling counterparts of `focus`/`blur`. The lesson is that bubbling is a per-event decision the *creator* makes, not a property of the DOM, and when you are the creator the decision is yours and the default is off. ## What non-bubbling actually means for delivery A non-bubbling event still goes through the full dispatch machinery. It is delivered at the target. What it does not do is continue upward afterwards, so no ancestor listener runs. One subtlety worth knowing: a non-bubbling event is still delivered to *capturing* listeners on ancestors on the way down, because the capture phase is not the bubble phase. So this fires: ```js container.addEventListener('item-selected', onSel, { capture: true }); child.dispatchEvent(new CustomEvent('item-selected')); // onSel runs ``` while the same listener registered without `capture` does not. Relying on that is a trap in review — if you want ancestors to hear you, set `bubbles: true` rather than forcing every consumer to register in capture mode. ## Being connected matters Dispatch walks the node's ancestor chain as it exists at dispatch time. If you build a widget in memory and dispatch before inserting it, the chain above the target is short or empty and `document`-level listeners see nothing: ```js const li = document.createElement('li'); li.dispatchEvent(new CustomEvent('item-selected', { bubbles: true })); // nothing on document fires — li has no connected ancestors yet ``` Append first, then dispatch — or accept that the event is target-local by design. ## Shadow boundaries are a second gate If the dispatching node lives inside a shadow root, `bubbles: true` gets the event up to the shadow root and then stops. Crossing into the host's tree requires `composed: true` as well. Both flags are needed; either one alone leaves an outside listener silent. ## Choosing the flag deliberately Bubbling is a public-API decision for a component: - **Bubble** when the event is a *notification to whoever is interested* — a list item announcing selection, a form field announcing validity. Consumers can then delegate from a container instead of wiring every instance. - **Do not bubble** when the event is a private handshake between two specific objects, or when the name is generic enough that an ancestor listening for the same string would get confused by events from unrelated descendants. Because the flag is fixed at construction, a component cannot change its mind per consumer. That makes it worth writing down: "this element emits `item-selected` (bubbles, composed, `detail: { id }`)" is as much a part of the contract as a function signature. ## The reusable emit helper ```js function emit(el, type, detail) { return el.dispatchEvent( new CustomEvent(type, { detail, bubbles: true, composed: true }) ); } ``` One helper makes the defaults explicit at a single site instead of leaving fifty dispatch calls silently relying on `false`.

  • Does a non-bubbling event reach a capturing listener on an ancestor?
    Yes. Capture and bubble are different phases, and `bubbles: false` only suppresses the upward phase. An ancestor listener registered with `{ capture: true }` is still invoked on the way down to the target. It is real behaviour, but a poor substitute for setting `bubbles: true`, because it forces every consumer to know the trick.
  • You set bubbles: true and a document-level listener still misses the event. What would you check?
    Two things. First, whether the target was connected to the document at dispatch time — dispatch walks the ancestor chain as it stands, so an in-memory node reaches nothing. Second, whether the target is inside a shadow root, in which case the event also needs `composed: true` to cross the boundary.
  • Should a component's events bubble by default?
    Bubble when the event is a notification any interested ancestor might want, since that enables container-level handling instead of per-instance wiring. Keep it target-local for private handshakes or generic names that an ancestor could confuse with an unrelated descendant's event. Either way, document the choice — it is part of the component's public contract.

saying these in an interview costs you the question

  • Assumes all DOM events bubble because click does
  • Tries to enable bubbling by changing the addEventListener options
  • Attempts to set ev.bubbles = true after constructing the event
  • Dispatches on a detached node and expects document listeners to run
  • Thinks bubbles: true alone escapes a shadow root

context