Search code examples
javascriptreactjsreact-hooksevent-handlingdom-events

Is there well-defined behavior for how React rerenders after receiving multiple simultaneous events?


Let's say I have a React component that may receive multiple events that perform state changes after one interaction, as shown below:

const {useRef, useState} = React;

function App() {
  const [_, setState] = useState(null);

  const renderCount = useRef(0);
  renderCount.current += 1;
  console.log("rendering");
  
  return (
  <div>
    {`Render count: ${renderCount.current} `}
    
    <span onClick={() => { 
      console.log("span clicked"); 
      setState({}); 
    }}>
      <button onClick={() => { 
        console.log("button clicked"); 
        setState({}); 
      }}>Send click events</button>
    </span>
  </div>
  );
}

ReactDOM.createRoot(
  document.getElementById("root")
).render( < App / > );
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"></script>

<div id="root"></div>

In the above example, after pressing the button to fire the click events, both event handlers run, but React batches the state updates to only rerender once.

However, if I attach these event handlers directly on the native HTML elements, as shown below, React rerenders twice per interaction, or rather after each event handler.

const {useRef, useState, useEffect} = React;

function App() {
  const [_, setState] = useState(null);
  const spanRef = useRef();
  const btnRef = useRef();

  const renderCount = useRef(0);
  renderCount.current += 1;
  console.log("rendering");
  
  useEffect(() => {
    const handler1 = () => { 
      console.log("span clicked"); 
      setState({}); 
    };
    
    const handler2 = () => { 
      console.log("button clicked"); 
      setState({}); 
    };
    
    const span = spanRef.current;
    span.addEventListener("click", handler1);
    
    const btn = btnRef.current;
    btn.addEventListener("click", handler2);

    return () => {
      span.removeEventListener("click", handler1);
      btn.removeEventListener("click", handler2);
    };
  }, []);
  
  return (
  <div>
    {`Render count: ${renderCount.current} `}
    
    <span ref={spanRef}>
      <button ref={btnRef}>Send click events</button>
    </span>
  </div>
  );
}

ReactDOM.createRoot(
  document.getElementById("root")
).render( < App / > );
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"></script>

<div id="root"></div>

I suspect this has something to do with React's synthetic event system, but I'm trying to understand the behavior exactly. For instance, will React always immediately rerender state updates after native event listeners? If so, do effects still run in between rerenders, or are they deferred until all event handlers have been executed?

Either answers or references to official documentation are appreciated. And to clarify, this isn't for a particular project or problem I'm solving, I'm just trying to better understand React's behavior.


Solution

  • The difference is (very probably) that,

    • when attaching the event listeners to the native HTML elements with addEventListener, clicking with the mouse invokes the event handlers asynchronously, whereas
    • the React events invoke the event handlers synchronously.

    I'd expect this is not well documented by React, because you ask about the order in which things happen, which you are not supposed to care about in React.

    I would even say that React might change the internal behavior at any time without changing the basic concept (e.g. the guarantees React makes about state).

    Anyway, I'll try to make sense of it here (I'm sorry, I need to guess a bit) ...

    React events

    Probably a relevant fact is that React attaches event handlers at the root.

    You can confirm that by logging the .currentTarget of the event.nativeEvent property, e.g.:

    <span onClick={ event => {
        console.log("span clicked:", event.nativeEvent.currentTarget, event.nativeEvent.target);
        setState({});
    }}>
        <button onClick={ event => {
            console.log("button clicked:", event.nativeEvent.currentTarget, event.nativeEvent.target);
            setState({});
        }}>Send click events</button>
    </span>
    

    Native events

    In javascript, nothing can run in parallel, so normal click events, initiated by e.g. the mouse, can not be executed while other javascript code is still running.

    So the click events are queued, and each is executed only when the call stack (aka "execution stack") is empty.

    Therefore,

    • the button click handler is executed together with the setState and the re-render of the virtual DOM,
    • then the span click handler can be executed, with the second setState and the re-render,
    • then the actual browser DOM is updated.

    Sythetic events

    I assume that React handles the events from multiple elements with the same internal event handler, and invokes the attached handlers using synthetic events.

    As synthetic events are always executed synchronously, all attached event handlers are executed before the internal React event handler returns, i.e.

    • button onClick is executed, together with setState, but then, instead of the re-render,
    • the span click handler is executed, with the second setState.
    • Then the re-render and DOM update happens.

    Inspect the events

    It's not possible to inspect or "tap into" the already attached event handlers (apart from browser dev-tools like "inspect node" and getEventListeners), but you can further examine the order in which the events are executed if you

    • add both, the native event handlers and the React event handlers to the elements,
    • add another console.log in a microtask, which will illustrate that the call stack was empty.

    E.g.:

    const handler1 = () => {
        console.log("Native event: spanRef click");
        queueMicrotask(() => { console.log('Native event: spanRef click (microtask)'); } )
        setState({});
    };
    
    const handler2 = () => {
        console.log("Native event: buttonRef click");
        queueMicrotask( () => { console.log('Native event: buttonRef click (microtask)'); } )
        setState({});
    };
    
    // ...
    
    <span ref={ spanRef } onClick={ () => {
        console.log('React event: span onClick');
        queueMicrotask( () => {
            console.log('React event: span onClick (microtask)');
        })
        setState({});
    }}>
        <button ref={ buttonRef } onClick={ () => {
            console.log( 'React event: button onClick' );
            queueMicrotask( () => {
                console.log( 'React event: button onClick (microtask)' );
            })
            setState({});
        }}>Send click events</button>
    </span>
    

    Which will log after a mouse click on the button:

    rendering 1
    Native event: buttonRef click               \
    rendering 2                                  | native button event completely executed
    Native event: buttonRef click (microtask)   /
    Native event: spanRef click                 \
    rendering 4                                  | native span event completely executed
    Native event: spanRef click (microtask)     /
         (Here probably Reacts internal root event handler executed)
    React event: button onClick                 \
    React event: span onClick                    |
    rendering 6                                  | All React events executed
    React event: button onClick (microtask)      |
    React event: span onClick (microtask)       /