In this article, we delve into the concept of event bubbling in React and DOM events, exploring how events propagate from the innermost element to the outermost element in the DOM tree.
We then examine the stopPropagation()
method, which can halt the propagation of events during the event bubbling phase, but only for events of the same type. To illustrate the limitations of stopPropagation, we examine a practical example of a checkbox card component.
Finally, we offer insights on how to overcome these limitations and create a fully clickable checkbox card component that can be interacted with anywhere in the card or container element.
Event bubbling
Dealing with multiple events within a React component can be simple if you understand how event bubbling works, but sometimes you need event handlers to be triggered only under certain conditions or circumstances.
In case you are not familiar with event bubbling, it is a mechanism in DOM where events propagate from the innermost element (i.e. target element) to the outermost element (e.g. body/container). When an event is triggered on an element, it first runs the handlers on it, and then on its parent, and so on, until it reaches the root element. This way, if multiple elements are nested, the event can be handled by any of those elements.
But what if I don’t want this behavior 🤔 ? What if I don’t want the event to bubble up in the chain of DOM elements?
Enter .stopPropagation()
The stopPropagation()
is a method on an event object that is used to stop the propagation of an event in the event bubbling phase.
For example, if there is an event handler attached to the parent element and one attached to the child element, calling stopPropagation
on the event in the child element's handler will prevent the parent element's handler from being executed:
const handleClick = (e) => {
e.stopPropagation() // <-- It will stop the click event to bubble up
doSomething();
}
This is useful when multiple event handlers are attached to elements in a nested structure and you only want the event to be handled by the element it was triggered on, not its parent elements.
If you've tried using stopPropagation
it in the past without success, it's important to note that there's a catch. stopPropagation
only stops the propagation of events of the same type. For instance, if you apply it to a click event, it will only prevent that event from bubbling to other outer click events, but won't affect other types of events.
So handling event bubbling becomes more complex when you are trying to fully stop the propagation of the event or at least not cause additional side-effects from other event handlers higher up in the DOM tree.
Real-world example - Checkbox Card component
A while ago I was tasked with creating a new reusable component, it was a “choice card” component that could either be a checkbox or a radio button. For simplicity and just to demonstrate the limitation of stopPropagation
I’ll simplify its implementation.
Simple implementation:
function App({ onChange }) {
const [checked, setChecked] = useState(false);
const handleCheckboxChange = () => {
setChecked((checked) => !checked);
// if an optional onChange function is passed, call with the current state
onChange && onChange(!checked)
};
return (
<div className="container">
<input
id="my-checkbox"
type="checkbox"
value="Type: Awesome selection"
checked={checked}
onChange={handleCheckboxChange}
/>
<label htmlFor="my-checkbox">Select me</label>
</div>
);
}
Enhancing its functionality
While initially, it may seem simple, there's a catch in the design requirement of making the entire card clickable. The goal is to allow the user to check or uncheck the choice by clicking anywhere within the card or its container element.
At first glance, one might assume that adding a similar click handler to the container element would solve the issue. However, let's put that theory to the test.
function App({ onChange }) {
const [checked, setChecked] = useState(false);
// rename and use the same handler for both events
const handleCheck = (e) => {
e.stopPropagation() // <-- This should stop the propagation, right?
console.log('I was invoked')
setChecked((checked) => !checked);
// if an optional onChange handler
onChange && onChange(!checked);
};
return (
<div className="container" onClick={handleCheck}>
<input
id="my-checkbox"
type="checkbox"
value="Type: Awesome selection"
checked={checked}
onChange={handleCheck}
/>
<label htmlFor="my-checkbox">Select me</label>
</div>
);
}
Upon trying this out, you will observe that clicking on the container section of the user interface yields the expected behavior. Specifically, clicking on the card selects or deselects the checkbox as expected. However, clicking directly on the checkbox itself doesn't change its state; if it's checked, it stays that way, and vice versa 🤔 .
The issue is that the handleCheck
function is triggered twice - once when you click the checkbox and once again at the container level due to event bubbling. Even if you pass the event to the handler and call the stopPropagation
method, it won't work as the events being triggered are of different types, a change
event and a click
event.
Important Considerations: I couldn’t switch the
onChange
toonclick
for the checkbox, since I was using a third-party component library, and the checkbox was already using theonChange
handler.In IE, the
onchange
event is only triggered when the checkbox loses focus. As a result, if you interact with the checkbox by tabbing to it and hitting space a few times before tabbing out, you will only receive oneonchange
event but multipleonclick
events.It's worth mentioning that this behavior in IE is actually correct according to the specs, while other browsers are incorrect in this instance.
The workaround
We are aware that the onChange
event cannot be stopped from bubbling up and triggering the onClick
event using stopPropagation
. However, we can set conditions for the container event to trigger the logic only when needed.
One solution is to set the handler of the container (since it is triggered when the event bubbles up), to activate the side effect only when the event originates from the same element.
A check can then be added as follows:
if (e.target === e.currentTarget) {
setChecked((checked) => !checked);
}
Since events bubble by default, there is a distinction between target
and currentTarget
:
target
is the element that triggered the event (e.g., the user clicked on)currentTarget
is the element that the event listener is attached to.
And here's what the end result will look like:
function App({ onChange }) {
const [checked, setChecked] = useState(false);
// checkbox handler
const handleCheck = () => {
setChecked((checked) => !checked);
onChange && onChange(!checked);
};
// container handler
const handleContainerClick = (e) => {
// only change the state when you click on the container 'div'
if (e.target === e.currentTarget) {
setChecked((checked) => !checked);
onChange && onChange(!checked);
}
};
return (
<div className="container" onClick={handleContainerClick}>
<input
id="my-checkbox"
type="checkbox"
value="Type: Awesome selection"
checked={checked}
onChange={handleCheck}
/>
<label htmlFor="my-checkbox">Select me</label>
</div>
);
}
Understanding the difference between target
and currentTarget
allow us to better control an event handler and implement our conditions for its execution.
Hope you enjoyed learning about event bubbling, stopPropagation, and how to conditionally call events when stopPropagation is not enough.
Bonus
You might need to know more information from the checkbox than the checked
state. Other information like the id or value might be helpful.
This is still a simplified version of the end result, but a more complete solution that can provide more information about the state of choice:
function App({ onChange = () => {} id, value }) {
const inputRef = useRef();
const [selection, setSelection] = useState();
// checkbox handler
const handleCheck = (e) => {
const { checked, id, value } = e.target;
if (!selection) {
const selection = {
checked,
id,
value
};
setSelection(selection);
onChange(selection);
} else {
setSelection(null);
onChange(null);
}
};
// container handler
const handleContainerClick = (e) => {
// triger the input onChange event
if (e.target === e.currentTarget) inputRef?.current?.click();
};
return (
<div className="container" onClick={handleContainerClick}>
<input
type="checkbox"
id={id}
value={value}
ref={inputRef}
checked={selection?.checked || false}
onChange={handleCheck}
/>
<label htmlFor="my-checkbox">Select me</label>
</div>
);
}