Evan Hessler
Evan Hessler

Reputation: 337

React onMouseEnter and onMouseLeave not behaving consistently

I have a div that is simply supposed to display 'HOVERING' if the cursor is hovering over it, and 'NOT HOVERING' otherwise. For some reason, it behaves as expected if I slowly hover each div on the page; however, if I quickly move my cursor across the screen, some of the divs become switched. Meaning, they will display "NOT HOVERING" when my cursor moves over the div, and "HOVERING" when my cursor is not over the div.

This error occurs in both Chrome and Safari.

Sandbox:

https://codesandbox.io/s/aged-butterfly-r2g6x?file=/src/Geo.js

Move your cursor quickly over the boxes to see the issue.

Upvotes: 4

Views: 12217

Answers (2)

Drew Reese
Drew Reese

Reputation: 203427

Issue

I think the main issue with your implementation is with the way asynchronous event callbacks are queued up and processed in the event loop. I can't find any hard details about the latency of processing event callbacks but the docs here and here may shed some more light on the matter if you care to do a deep dive.

Basically the issue is two-fold:

  1. There is a minute duration a single event loop takes to process, i.e. detect an event and add it to the queue. I suspect the mouse is moving fast enough off/out the screen or into another div it isn't detected. The divs "jumping"/"moving" when hovering also doesn't help much.
  2. The component logic assumes all events can and will be detected and simply toggled the previous existing state. As soon as an event is missed though the toggling is inverted, thus the issue you see. Even in the updated sandbox this latency can cause one of the elements to get "stuck" hovered

Proposed Solution

Add a mouse move event listener to the window object and check if the mouse move event target is contained by one of your elements. If not currently hovered and element contains event target, set isHovered true, and if currently hovered and the element does not contain event target, set isHovered false.

This isn't a full replacement for the enter/leave|over/out event listeners attached to the containing div as I was still able to reproduce an edge-case. I noticed your UI is most susceptible to this issue when moving the mouse quickly and leaving the window.

Combining the window and div event listeners gives a pretty good resolution (though I was still able to reproduce edge-case it is much more difficult to do). What also seems to have helped a bit is not defining anonymous callback functions for the div.

import React, { createRef } from "react";

export default class Geo extends React.Component {
  state = {
    isHovering: false
  };
  mouseMoveRef = createRef();

  componentDidMount() {
    window.addEventListener("mousemove", this.checkHover, true);
  }

  componentWillUnmount() {
    window.removeEventListener("mousemove", this.checkHover, true);
  }

  setHover = () => this.setState({ isHovering: true });
  setUnhover = () => this.setState({ isHovering: false });

  checkHover = e => {
    if (this.mouseMoveRef.current) {
      const { isHovering } = this.state;
      const mouseOver = this.mouseMoveRef.current.contains(e.target);
      if (!isHovering && mouseOver) {
        this.setHover();
      }

      if (isHovering && !mouseOver) {
        this.setUnhover();
      }
    }
  };

  render() {
    var textDisplay;

    if (this.state.isHovering) {
      textDisplay = <span>HOVERING</span>;
    } else {
      textDisplay = <h1>NOT HOVERING</h1>;
    }

    return (
      <div
        ref={this.mouseMoveRef}
        onMouseEnter={this.setHover}
        onMouseLeave={this.setUnhover}
        style={{ width: 300, height: 100, background: "green" }}
      >
        {textDisplay}
      </div>
    );
  }
}

Edit friendly-chatterjee-xx2ms

Upvotes: 8

Nahuel
Nahuel

Reputation: 171

As far as I can see, you have a problem with the way you update the state. Bear in mind that React may update the state asynchronously.

Changing toggleHoverState function will solve the issue

toggleHoverState() {
    this.setState(state => ({isHovering: !state.isHovering}));
  }

Go to this section in React docs for more info

Upvotes: 0

Related Questions