Everything about event bubbling/capturing

Definition

What is event bubbling?

Most events attached to the DOM node do propagate (bubble/capture). In other words, when an event is triggered

  • It first goes from the most parent event (i.e., the window object) to the lowest descendant. This phase is called capturing.
    Note: IE does not support event capture.
  • After that, the event goes back from the lowest descendant to the highest ascendant (the window object). This phase is called bubbling.
Events' capturing and bubbling
Event capture compatibility table

Event capture with react

Due to the inconsistency across browsers regarding the support for event capture in particular and event support in general, react encapsulates the native events in the React's Event System called SyntheticEvent.

The default on... props are for the bubbling phase, to listen for the events in the capture phase, suffix the prop names with Capture. For example: onClickCapture, onChangeCapture, ...

From react docs

The event handlers below are triggered by an event in the bubbling phase. To register an event handler for the capture phase, append Capture to the event name; for example, instead of using onClick, you would use onClickCapture to handle the click event in the capture phase.

React supports an extensive range of events, namely: copy, cut, paste, compositionend, compositionstart, compositionupdate, keydown, keypress, keyup, blur, focus, change, input, invalid, reset, submit, error, load, select, touchcancel, touchend, touchemove, touchstart, scroll, wheel, animationstart, animationend, animationiteration, transitionend, toggle, media events, pointer events, mouse events.


Document type

Note: I said window is the highest ascendant object in the event bubbling/capture tree. One may question what the nearest child element of the window object is.

The following facts can solve this question:

  • The equivalent javascript DOMElement of the <html> tag is document.documentElement.
  • document is the parent of the <html> element.
  • window is a parent of the document object.
// document.documentElement is the top DOM element being displayed, not document
document.documentElement === document.getElementsByTagName('html')[0] // true

// document is type Document, <html> is type Element
document.documentElement instanceof Element // true
document.documentElement instanceof Document // false

document instanceof Element // false
document instanceof Document // true

// they both are Node type, because Element and Document inherit Node
document instanceof Node // true
document.documentElement instanceof Node // true

// window is not Node type
window instanceof Node // false
window instanceof Window // true

// Document and Element have the different implementations
document.getElementsByClassName === document.documentElement.getElementsByClassName // false
document.body.getElementsByClassName === document.documentElement.getElementsByClassName // true

// Several methods do not exist in the Element's prototype
document.body.getElementById === undefined //  true
typeof document.getElementById === 'function' // true

Hence, some top objects in the event tree are window -> document -> document.documentElement (<html> element) -> ...


Which events do not bubble?

Most events do bubble. There are several exception events that do not bubble. Some of them are:

This information can be checked from MDN Event reference or W3C UI Events.

The capability of bubbling/capturing is defined when constructing the event via the bubbles: boolean option.

Are non-bubble events captured?

The answer is yes, unless the browser does not support event capture. See this codepen to confirm.

non-bubble events are captured (in browsers that support event capture)

Event listener firing order

Phase specification

EventTarget.addEventListener() is used to add a handler to an event. The useCapture option is used to specify whether the handler is added to the capture phase or the bubble phase.

Most today browsers do not require the useCapture option and use the default false value. However, as noted by MDN docs, there are some cases that require this option, such as IE prior to version 9. Thus, to improve your code compatibility, you should always explicitly set this option.

Note: useCapture has not always been optional. Ideally, you should include it for the widest possible browser compatibility.
useCapture isn't optional in all browsers historically

Firing order

From the illustration, it is trivial to infer that event listeners in the capture phase are fired before the bubble phase.

In the target phase, where the event handlers attached to the event target are fired, the useCapture parameter is ignored and the event handlers are executed in the order they were registered.

Capture phase is executed before the bubble phase

From MND docs

Note: For event listeners attached to the event target, the event is in the target phase, rather than the capturing and bubbling phases. Events in the target phase will trigger all listeners on an element in the order they were registered, regardless of the useCapture parameter.

Canceling an event

There are three types of event canceling.

All three calls have different event listeners which are affected. None of them affects the target listeners of any other.

preventDefault() v.s. stopPropagation() v.s stopImmediatePropagation()

Event target

The are several target-related properties in the event object which are handy to obtain useful context information because sometimes the element that triggers the event is not always the element where the event listener is attached to. Some of these properties are:

Event's various target types
relatedTarget value in focusin/focusout events

Note: In vanilla javascript, and React prior to version 17 (see Event Pooling), the event object is re-used in the capture/bubble itinerary. See this pen to confirm this fact in a vanilla script, or this pen for React 17 version.

This means that if the event listener is asynchronous, you should cache the event's properties locally, otherwise they may be changed unexpectedly.

const clickHandler = evt => {
  const {currentTarget} = evt // cache property locally
  setTimeout(() => {
    console.log('evt.currentTarget changed', evt.currentTarget !== currentTarget)
  }, 3000)
}

For mousenter, mouseleave, mouseout, mouseover, dragenter, dragexit, blur, focus, focusin, focusout events, the event has one more property named relatedTarget which points to the secondary target, where target is the primary target. Use the following structure for your convenience to infer them.

Event do <action> the <primary target> from/to the <secondary target>

For example:

  • mouseenter: mouse enters/overs/dragenter event.target from event.relatedTarget
  • mouseleave: mouse leaves/outs/dragexit event.target to event.relatedTarget

Focus/Blur events

relatedTarget property

There are 4 types of focus/blur events: blur/focus (non-bubble), focusin/focusout (bubble).

There was a bug from many years ago in Firefox (it has been fixed along), where relatedTarget is null on all these 4 events.

Even though the W3C specs on blur, focus, focusin, focusout does require relatedTarget.

FocusEvent.relatedTarget : event target receiving focus.
FocusEvent.relatedTarget : event target losing focus (if any).

IE only assigns the relatedTarget on focusin/focusout events, while it does not set on focus/blur events (source).

React's onBlur/onFocus

Prior to version 17, React checks for the availability of event capturing to use the blur/focus events; If not, use the focusin/focusout event. This causes several problems.

From version 17, they decided to use focusin/focusout under the hood for onFocus/onBlur.

Anyway, regardless of react version, react's onBlur/onFocus is not native focus/blur events, because in react onBlur/onBlur do bubble while the native focus/blur events do not bubble.

Check if an element loses/gains its focus in react

Because the onFocus/onBlur events bubble in react, if a child element gains/loses from from/to its sibling element.

onBlur and onFocus events in React

To correctly determine if the target element (where the event listener is attached to) loses/gains its focus, check for evt.currentTarget === evt.target.

The following source code is copied from here.

export default function App() {
  return (
    <div
      tabIndex={1}
      onFocus={(e) => {
        console.log("focusin (self or child)");
        if (e.currentTarget === e.target) {
          console.log("focus (self)");
        }
        if (!e.currentTarget.contains(e.relatedTarget)) {
          console.log("focusenter");
        }
      }}
      onBlur={(e) => {
        console.log("focusout (self or child)");
        if (e.currentTarget === e.target) {
          console.log("blur (self)");
        }
        if (!e.currentTarget.contains(e.relatedTarget)) {
          console.log("focusleave");
        }
      }}
    >
      <input />
      <input />
    </div>
  );
}

How much does .addEventListener / .removeEventListener affects the performance?

I added this session because I saw many people care about tuning their code to save several calls of .addEventListener() in the effect callback.

My suggestion is that: please do NOT do that. Instead, save your time and spend the investigation on the other critical portion of your program. .addEventListener/.removeEventListener are very cheap in terms of performance.

The following snippets perform relatively the same. Precisely speaking, the .addEventListener versions are at least faster or equal to the later versions.

function App(){
  const ref = useRef()
  const cb = () => console.log('element is clicked')
  useEffect(() => {
    ref.current.addEventListener('click', cb, false)
    return () => void ref.current.removeEventListener('click', cb)
  }) // run in every render
  return <button type="button" ref={ref}>Click me!</button>
}
function App(){
  const ref = useRef()
  const cb = () => console.log('element is clicked')
  useEffect(() => {
    ref.current.addEventListener('click', cb, false)
    return () => void ref.current.removeEventListener('click', cb)
  }, [cb]) // run in every render because cb always changes
  return <button type="button" ref={ref}>Click me!</button>
}
function App(){
  const cb = () => console.log('element is clicked')
  return <button type="button" onClick={cb}>Click me!</button>
}
function App(){
  return <button type="button" onClick={() => console.log('element is clicked')}>Click me!</button>
}