iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔍

Causes and Solutions for Click Events Firing Twice When a <button> is Wrapped in a <label>

に公開
1

Hello everyone. Today, I'll explain an issue I recently encountered where a click event can fire twice when a <label> is wrapping a <button>.

First, please take a look at this CodePen.

Here, a button with "0" is displayed. This button is implemented so that clicking it once increases the number by 1.

However, clicking the number displayed on the button increases the number by 2. This is because the click event is firing twice. Clicking other parts (the edges of the button or the label) increases the number by 1.

The HTML and JavaScript implementations are as follows:

<p>Clicking the number triggers onClick twice!!!</p>
<div>
<label>
  Label test
  <button type="button"></button>
</label>
</div>
let count = 0;

const writeButtonContent = () => {
  const button = document.querySelector('button');
  const span = document.createElement('span');
  span.textContent = String(count);
  button.replaceChildren(span);
}

writeButtonContent();

document.querySelector('button').addEventListener('click', () => {
  console.log('click');
  count++;
  writeButtonContent();
})

Anyone who can explain why this phenomenon occurs is quite familiar with HTML and the DOM. It took me a little while to understand it as well.

In this article, I will explain the cause of this phenomenon and introduce some countermeasures.

Preliminary Knowledge: About the label element

I'll briefly explain the label element. While the label element has roles related to accessibility, focusing strictly on behavior, clicking a label results in the associated form control being clicked. This is commonly used in combination with <input type="checkbox"> or <input type="radio">.

<label>
  <input type="checkbox" name="lost"> Lost
</label>

By doing this, clicking the text "Lost" also counts as clicking the checkbox.

In the initial example, the label element wraps a button element. Since labels also support the button element, clicking the label counts as clicking the button element as well.

Causes of the Double Click Event

Now, let's get to the main topic.

In the initial example, the DOM structure after the JavaScript executes is as follows (the p and div elements are omitted as they are irrelevant here).

<label>
  Label test
  <button type="button"><span>0</span></button>
</label>

Diagram showing the DOM structure. Under the label element is a button element, under which is a span element, under which is the text node 0.
Initial DOM structure

The click event fires twice when the span element inside the button element is clicked. Upon debugging, it becomes clear that the first click event is fired on the span element, while the second click event is fired on the button element.

The key factors are event bubbling and the behavior of the label element. Additionally, the fact that DOM rewriting occurs during the click event in this sample is also relevant.

First, the first click event occurs at the span element (in other words, the event's target is the span element). Then, due to event bubbling, the click event handler is processed at its parent button element.

Diagram showing that the span element is the event source and it reaches the button element via event bubbling.
Event bubbling process

At this point, the DOM is updated as follows due to DOM rewriting:

<label>
  Label test
  <button type="button"><span>1</span></button>
</label>

Specifically, the content of the button element has become <span>1</span>, but this span element itself is newly created. In other words, the original <span>0</span> element has been removed from the DOM tree.

And the event processing continues.

Diagram showing the source span element detached from the DOM tree and a new span element with a text node "1" created under the button element. Furthermore, event bubbling is proceeding beyond the label element.
DOM after rewriting and the event bubbling process

The behavior of the label element (when there is no for attribute) is that when the label element is clicked, it fires a click event on the form control [1] contained within it. This is the second click event fired on the button element.

However, simply wrapping a button element with a label element does not normally cause this issue. The fact that DOM rewriting was performed is one of the causes of this phenomenon.

In other words, when the label element processes the first click event, it likely determines, "If this click event was originally triggered by the button element being clicked, there's no need to fire an additional click event." Because of this, simply clicking a button element under normal circumstances won't fire the click event twice.

In this case, since the span element that triggered the first click has been removed from the DOM tree by this point, it is likely judged that the source of the trigger is not inside the button element. Consequently, the label element fires a second click event.

This is the reason why the button element's click event is processed twice just by clicking the button (its content) once. Rewriting the DOM in this way during a click event often happens when using a button element like a toggle button. I encountered this problem in a React application.

Checking the Specifications

This issue occurred because, during the label element's click processing, it was mistakenly determined that the trigger source was not the button element, causing the click event to be processed twice.

So, is there any basis for this behavior? The behavior of browsers for HTML documents is defined in the HTML Specification. Of course, there is a definition for the label element as well. Let's check the description in the specification.

Let's quote the part that gets straight to the heart of the matter.

The label element's exact default presentation and behavior, in particular what its activation behavior might be, if anything, should match the platform's label behavior. The activation behavior of a label element for events targeted at interactive content descendants of a label element, and any descendants of those interactive content descendants, must be to do nothing.

Consider the activation behavior mentioned here as the default behavior of an element defined by the specification. For example, navigating to a page when a link is clicked, or submitting a form when a <button type="submit"> is clicked, are examples of activation behaviors.

As you can see from reading this, the activation behavior of a label element is not defined as a specific algorithm. Instead, it is stated that it "should match the platform's label behavior." Regarding this, it is mentioned as an example:

For example, on platforms where clicking a label activates the form control, clicking the label in the following snippet could trigger the user agent to fire a click event at the input element, as if the element itself had been triggered by the user:

<label><input type=checkbox name=lost> Lost</label>

The behavior described in this example is the general behavior of labels on PCs and other devices. As far as I have confirmed, this is the behavior on both PCs and smartphones. However, this is not absolute; the specification mentions that other possible behaviors include "just gaining focus" or "nothing happening."

The issue here concerns the only part of the label element's activation behavior that was marked as "must":

The activation behavior of a label element for events targeted at interactive content descendants of a label element, and any descendants of those interactive content descendants, must be to do nothing.

Since a button element falls under interactive content, the case where "a span inside the button element is clicked" corresponds to this scenario. Therefore, under normal circumstances, the label element's activation behavior should do nothing.

The specification does not seem to explicitly state when this determination (whether the event target is interactive content) should be made.

While I haven't confirmed the browser implementation, I suspect the determination is made at the moment the activation behavior is triggered. As defined in the specification, the activation behavior is triggered after the entire event bubbling process is complete [2]. If this is the case, as mentioned above, the target span element has already been removed from the DOM tree at the time of determination, leading to the behavior where the activation behavior fires a click event for the button element.

However, since it is not currently strictly defined in the specification when to make the determination, a browser that correctly makes the determination even in the situation described in this article and does not fire the second click would not be in violation of the specification [3].

Countermeasures for the Double Click Event

Now that we understand the cause of the problem, let's look at some countermeasures. There are two main solutions.

Using preventDefault

Activation behavior can be canceled by preventDefault. Therefore, by calling preventDefault in the button element's click event handler, you can cancel the label element's activation behavior even if the button is wrapped in a label.

 document.querySelector('button').addEventListener('click', (event) => {
+  event.preventDefault();
   console.log('click');
   count++;
   writeButtonContent();
})

Note that you cannot stop this using stopPropagation instead of preventDefault. This is because stopPropagation prevents event listeners from being triggered during event bubbling, but it does not stop the activation behavior that occurs after the bubbling process is complete.

This method generally has fewer drawbacks and is a suitable countermeasure in most cases. In practice, if the button element is a component where you don't know if the parent is a label, you might unintentionally cancel the activation behavior of other parent elements. However, since the HTML specification prohibits nesting interactive content, this is unlikely to cause issues in practical use.

Using pointer-events: none;

Another countermeasure is to specify pointer-events: none; for the contents of the button.

button span {
  pointer-events: none;
}

By doing this, the target of the click event will always be the button element, regardless of where on the button you click.

In our example, the issue was that the source of the event was removed from the DOM tree. Since the button element itself remains in the tree, the label element's activation behavior correctly identifies the trigger source, preventing the second click event from firing.

This is also a good approach, but care should be taken if the button is a component and its content is unknown, as descendant elements might override it with pointer-events: auto;.

Conclusion

In this article, I explained the issue where a click event fires twice when a <label> wraps a <button>.

We found that this occurs when the content of a button element is rewritten within its event handler, causing the source (target) element to be detached from the DOM tree. This is a common scenario in modern applications, such as those built with React.

I introduced two countermeasures: using preventDefault and using pointer-events: none;. You can choose the one that best fits your specific case.

If you have button components that update their content, try wrapping them in a <label>. You might be surprised to find that click events are firing twice!

脚注
  1. More accurately, a labelable element ↩︎

  2. Remember that calling preventDefault at any point during event bubbling can cancel the activation behavior. If so, it must occur after event bubbling is complete. ↩︎

  3. However, if the behavior actually differed between browsers, I expect they would move toward unifying browser behavior and tightening the specification. ↩︎

GitHubで編集を提案

Discussion