A look at the browser's event life cycle
October 06, 2019
This is one of my favorite concepts to ask candidates about during an interview. The browser’s event life cycle is something that a front-end developer can develop against for some time without fully understanding. I find it to be a good gauge of a person’s experience and overall knowledge of the web platform, especially since responding to events is so central to the applications we build.
Event-driven architecture
Very briefly, an event-driven architecture is a software architectural pattern that enables loosely coupled components and services. An event-driven system consists of event emitters and consumers. The event emitter is responsible for detecting and transmitting an event; it is unaware of any consumers that may or may not exist. Conversely, the event consumer is responsible for reacting to a given event.
Even with this very brief definition, it should be apparent that the browser’s Document Object Model (DOM) is an event-driven system. The browser is responsible for detecting the different classes of events, e.g. ‘click’, ‘load’, etc. It is the event emitter.
When we attach an event listener to a DOM element using addEventListener
, as in the following example, it becomes the event consumer.
const onClick = event => console.log('event', event);
const someButton = document.getElementById('some-button');
someButton.addEventListener('click', onClick);
Event life cycle
Now that we have a high-level understanding of the event-driven architectural pattern and how the browser and DOM elements fit into that paradigm, let’s take a deeper look into the event life cycle. For our analysis, we’ll reference the following basic HTML document:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Event life cycle</title>
</head>
<body>
<button id="button">Activate!</button>
</body>
</html>
When the above HTML document is parsed, the following Abstract Syntax Tree (AST) is generated by the browser, which we will reference through what follows.
The event life cycle consists of 3 phases:
- Capture Phase
- Target Phase
- Bubble Phase
Capture Phase
In the Capture Phase, the event is dispatched from the root ancestor to each ancestor element in the tree until it reaches the target, at which point the Target Phase begins.
Using our reference HTML document, the event will go from the document
to the body
elements as part of the capture Phase.
Target Phase
In the Target Phase, the event is dispatched to the target of the event.
If the button
is clicked, it is the event.target
. During the Target Phase, if the button
has a registered event listener, it will be triggered.
Bubble Phase
In the Bubble Phase, the event “bubbles” or goes back down the tree through each ancestor in the tree until it reaches the root ancestor.
Using our reference HTML document, the event will go from the body
to the document
, where the event life cycle ends.
Listening for events
While an individual event goes through all 3 phases of the event life cycle, we control whether an element (the event consumer) listens to the Capture or Bubble Phase.
You will recall that addEventListener
takes 3 parameters. The first parameter is the event type as a string. The second is the function called when the specified event occurs. The third parameter is flexible.
If you pass a false
or the leave the third parameter undefined
, it means that you want this event listener to trigger in the Target and Bubble phases.
If you pass true
, though, it means you want the listener to trigger during the Capture and Target phases.
You can also pass an options object as the third parameter of addEventListener
. If you pass { capture: true }
, it means you want the listener to trigger during the Capture and Target phases. This is the same as passing true
as the third parameter.
Listening to the Target and Bubble phases
Let’s assume we have the following event listeners attached to the document
, body
, and button
, and a user clicks on the button. Notice that we’re leaving addEventListener
’s third parameterundefined
.
const phases = {
1: 'capture',
2: 'target',
3: 'bubble',
};
const onClick = (event) => console.log(`
currentTarget: ${event.currentTarget}
target: ${event.target}
phase: ${phases[event.eventPhase]}
`);
const button = document.getElementById('button');
document.addEventListener('click', onClick);
document.body.addEventListener('click', onClick);
button.addEventListener('click', onClick);
The resultant console output will be:
currentTarget: [object HTMLButtonElement]
target: [object HTMLButtonElement]
phase: target
currentTarget: [object HTMLBodyElement]
target: [object HTMLButtonElement]
phase: bubble
currentTarget: [object HTMLDocument]
target: [object HTMLButtonElement]
phase: bubble
Listening to the Capture and Target phases
Let’s assume we have the following event listeners attached to the document
, body
, and button
, and a user clicks on the button. Here we’re going to pass the options object, but we could have achieved the same thing by passing true
as addEventListener
’s third parameter.
document.addEventListener('click', onClick, { capture: true });
document.body.addEventListener('click', onClick, { capture: true });
button.addEventListener('click', onClick, { capture: true });
The resultant console output will be:
currentTarget: [object HTMLDocument]
target: [object HTMLButtonElement]
phase: capture
currentTarget: [object HTMLBodyElement]
target: [object HTMLButtonElement]
phase: capture
currentTarget: [object HTMLButtonElement]
target: [object HTMLButtonElement]
phase: target
In the above example, you’ll notice that even though we specified { capture: true }
on the button’s addEventListener
, it was ignored because it was the target of the event.
Listening to a mix of phases
You can mix and match to which phases elements listen. For example, let’s say you want the document
to listen to the Bubble Phase and the body
to listen during the Capture Phase:
document.addEventListener('click', onClick);
document.body.addEventListener('click', onClick, { capture: true });
button.addEventListener('click', onClick);
The resultant console output will be what you’d expect:
currentTarget: [object HTMLBodyElement]
target: [object HTMLButtonElement]
phase: capture
currentTarget: [object HTMLButtonElement]
target: [object HTMLButtonElement]
phase: target
currentTarget: [object HTMLDocument]
target: [object HTMLButtonElement]
phase: bubble
The stopPropagation
method
The Event’s stopPropagation
method prevents further propagation of an event in the Capture, Target, or Bubble phases, depending on which phase the element is listening.
Given our previous example where the document
is listening to the Bubble Phase and the body
is listening to the Capture Phase, let’s adjust it slightly so that we stop propagating the event in the body
’s addEventListener
in the Capture Phase.
document.addEventListener('click', onClick);
document.body.addEventListener('click', (event) => {
event.stopPropagation();
onClick(event);
}, { capture: true });
button.addEventListener('click', onClick);
The resultant console output will be:
currentTarget: [object HTMLBodyElement]
target: [object HTMLButtonElement]
phase: capture
Having stopped propagation in the Capture Phase, we see that the event never reaches the button
(or the Target Phase); nor does it get dispatched to the document
in the Bubble Phase.