iTranslated by AI

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

Revisiting Event Propagation in React

に公開

Introduction

A conversation during dinner one day.
Me: "Today, when I clicked a button, other elements responded as well, and I had a hard time."
Partner: "I see. Why did that happen?"
Me: "It seems the cause was that I didn't stop the event propagation."
Partner: "Hmm, so how did you resolve it?"
Me: "It worked out once I used stopPropagation."
Partner: "I see. But why does that make it work?"
Me: "Well, uh... because stopPropagation is what stops event propagation..."
Partner: "No, no. What I want to know is why using stopPropagation can be said to suppress event propagation. In the first place, isn't there also preventDefault for event control? What's the difference? Also, what is event propagation anyway? Why does an event reach other elements even though you only clicked that specific button? Isn't it strange that an event transmits to other elements? I want you to explain those points."
Me: "I'm sorry, I don't know..."
Partner: "..."
Me: "..."
This dinner scene is a fiction, but I realized I wouldn't be able to answer if I were actually asked these questions.
So, this time I would like to take another look at event propagation in JavaScript.
Note that React is used for the code sections.
This is simply because I am currently studying React and using it, not because React is particularly easy to explain this with.
I hope you find this helpful.

About DOM Event Propagation

When a click event occurs, such as when clicking a button on the screen, the event processing is not executed solely by that button itself.
As shown in the diagram below, events propagate in order starting from the top-level Window object. Once the event reaches the element that was the source of the event, it propagates back towards the Window object.
**Quoted from Basics of JavaScript, HTML, and DOM #2 Event Edition**
Quoted from Basics of JavaScript, HTML, and DOM #2 Event Edition
If processing for a click event is defined during the "bubbling phase"—where the event propagates from the source back towards the Window object—that processing will be executed.
Therefore, when passing through the bubbling phase, if click events are set on elements other than the source element, those processes will also be executed.
Since this can cause unexpected behavior, JavaScript is equipped with features to control this propagation.

Controlling Event Propagation in React

In React, event control is primarily performed as follows:

const test = (e: React.MouseEvent) => {
  e.stopPropagation();
  e.preventDefault();
};
return (
  <>
    <button onClick={test}></button>
  </>
);

e.stopPropagation() prevents the bubbling phase that we saw in the previous diagram from occurring.
As a result, the event occurring on the button element does not propagate to its parents, meaning only the processing for the event on the button element itself is executed.
e.preventDefault() cancels the default behavior associated with an event.
For example, it cancels actions that occur even without explicit implementation, such as navigating to a destination when clicking a link or submitting a form when clicking a submit button.
By the way, please note that e.preventDefault() does not suppress event propagation.
For example, take a look at the following code:

function App() {
  const handleParent = () => alert("Parent");
  const handleMy = () => alert("Me");
  const handleChild = (e: React.MouseEvent) => {
    e.preventDefault();
    alert("Child");
  };
  const parentStyle = {
    height: "200px",
    width: "200px",
    border: "1px solid black",
  };
  const myStyle = {
    height: "100px",
    width: "100px",
    border: "1px solid red",
    margin: "auto",
  };
  const childStyle = {
    display: "block",
    height: "50px",
    width: "50px",
    border: "1px solid black",
    margin: "auto",
  };
  return (
    <>
      <div onClick={handleParent} style={parentStyle}>
        Parent
        <div onClick={handleMy} style={myStyle}>
          Current Element
          <a href="https://google.com" onClick={handleChild} style={childStyle}>
            Child
          </a>
        </div>
      </div>
    </>
  );
}
export default App;

When displayed on the screen, it looks like this:
2023-12-16_11h10_51.png
In this state, clicking inside the "Child" box will not navigate to the link destination because e.preventDefault() is set.
However, because event propagation still occurs, alerts will be displayed in the following order: "Child", "Me", and "Parent".
To ensure only the "Child" alert is shown while preventing navigation, you must include both propagation suppression and event cancellation as follows:

const handleChild = (e: React.MouseEvent) => {
  e.preventDefault();
  e.stopPropagation();
  alert("Child");
};

When preventDefault Doesn't Work Well in React

There are cases where e.preventDefault() doesn't work as expected.
This happens with events where "Passive mode" is enabled.
For example, let's look at the wheel event.
When you write code like the following, you might intuitively expect that scrolling would stop when you rotate the wheel.

function App() {
  const wheelParent = (e: React.WheelEvent) => e.preventDefault();
  const parentStyle = {
    height: "2000px",
    width: "200px",
    border: "1px solid black",
  };
  return (
    <>
      <div style={parentStyle} onWheel={wheelParent}>
        Parent
      </div>
    </>
  );
}
export default App;

However, in reality, an error occurs when you rotate the wheel.
This is related to the Passive mode of events.
Passive mode is a state where it is assumed that preventDefault will not occur.
In normal events, because there is a possibility that preventDefault might be called, the browser processes the event after checking whether preventDefault has been invoked.
However, because of this check, events like wheel events experience a delay between the actual operation and the reflection on the screen.
Therefore, React enables Passive mode for events that require immediate reflection, such as wheel and touch events.
Since Passive mode is enabled, attempting to cancel the event with preventDefault will cause an error.
If you absolutely want to disable Passive mode for an event where it is enabled, you can write it as follows:

function App() {
  const wheelParent = (e: React.WheelEvent) => e.preventDefault();
  const divRef = useRef<HTMLElement>(null);
  useEffect(() => {
    const div = divRef.current;
    div?.addEventListener(
      "wheel",
      (e) => {
        const event = e as unknown as React.WheelEvent<HTMLElement>;
        wheelParent(event);
      },
      { passive: false }
    );
    return () => {
      div?.removeEventListener("wheel", (e) => {
        const event = e as unknown as React.WheelEvent<HTMLElement>;
        wheelParent(event);
      });
    };
  });
  const parentStyle = {
    height: "2000px",
    width: "200px",
    border: "1px solid black",
  };
  return (
    <>
      <div style={parentStyle} ref="divRef">
        Parent
      </div>
    </>
  );
}
export default App;

This allows you to execute the wheel event processing while disabling Passive mode.

Executing Event Handlers in the Capturing Phase in React

Up until now, I have mentioned that event processing runs in the bubbling phase, but events can also be executed in the capturing phase.
By appending "Capture" to the end of the event attribute, you can execute the event's processing during the capturing phase.
Take a look at the code below to see how this works.

function App() {
  const handleParent = () => alert("Parent");
  const handleMy = () => alert("Me");
  const handleChild = (e: React.MouseEvent) => {
    e.preventDefault();
    alert("Child");
  };
  // ...omitted
  return (
    <>
      <div onClickCapture={handleParent} style={parentStyle}>
        Parent
        <div onClick={handleMy} style={myStyle}>
          Current Element
          <a
            href="https://google.com"
            onClickCapture={handleChild}
            style={childStyle}
          >
            Child
          </a>
        </div>
      </div>
    </>
  );
}
export default App;

The onClickCapture attribute is specified on the div element where "Parent" is set.
Then, when the "Child" link is clicked, the "Parent" alert is displayed first, followed by "Child" and then "Me".
In this way, you can control the order of execution by triggering events in the capturing phase as well as the bubbling phase.

Side Note: Event Types for Arguments in React

Here are the React event types I've confirmed so far:

interface ClipboardEvent
interface CompositionEvent
interface DragEvent
interface PointerEvent
interface FocusEvent
interface FormEvent
interface InvalidEvent
interface ChangeEvent
interface KeyboardEvent
interface MouseEvent
interface TouchEvent
interface UIEvent
interface WheelEvent
interface AnimationEvent
interface TransitionEvent

When passing an event as an argument, it defaults to the any type in TypeScript, so you need to specify the type explicitly.
Please use this list as a reference.
Additionally, it seems that ChangeEvent can generally cover cases where you want to get some related value when changing a value.

Conclusion

In this article, we took another look at how events propagate.
I went from having a vague understanding to being able to explain, to some extent, the meanings and differences between stopPropagation and preventDefault, and how events fire in the first place.
I'm glad that I'll be able to explain the intent behind using stopPropagation and preventDefault from now on.
Thank you for reading this far.

Discussion