reactjsreact-hooksaddeventlistenermousemoveremoveeventlistener

Add and remove mousemove listener on window with React Hooks


I'm trying to add an event listener on the window when an object is clicked, and then remove that event listener when the object is again clicked.

When a Card component is clicked, state isCardMoving is toggled on or off.

I added a useEffect to watch isCardMoving. When isCardMoving is toggled on, it should add a mousemove event listener to the window that triggers the handleCardMove function. This function just logs the coordinates of the mouse.

if I click the card again, isCardMoving will be false, and I would expect the event listener on the window to be removed inside the useEffect.

What happens, though, is that the event listener will get added when isCardMoving is true, and then will not be removed once isCardMoving is false.

import React from 'react';

const App = () => {
  const [isCardMoving, setIsCardMoving] = React.useState(false);

  React.useEffect(() => {
    if (isCardMoving) window.addEventListener('mousemove', handleCardMove);
    else window.removeEventListener('mousemove', handleCardMove);
  }, [isCardMoving]);

  const handleCardMove = (event) => console.log({ x: event.offsetX, y: event.offsetY });

  return <Card onClick={() => setIsCardMoving(!isCardMoving)} />;
};

I then tried to set a ref on the window thinking that maybe I would need a previous reference to the window for some reason:

import React from 'react';

const App = () => {
  const [isCardMoving, setIsCardMoving] = React.useState(false);

  const windowRef = React.useRef(window); // add window ref

  // update window ref whenever window is updated
  React.useEffect(() => {
    windowRef.current = window;
  }, [window]);

  React.useEffect(() => {
    // add and remove event listeners on windowRef
    if (isCardMoving) windowRef.current.addEventListener('mousemove', handleCardMove);
    else windowRef.current.removeEventListener('mousemove', handleCardMove);
  }, [isCardMoving]);

  const handleCardMove = (event) => console.log({ x: event.offsetX, y: event.offsetY });

  return <Card onClick={() => setIsCardMoving(!isCardMoving)} />;
};

This seems to have the same effect as before.


Solution

  • You can't remove an event listener like this in React or other virtual DOM-based applications. Due to the nature of virtual DOMs libraries, you must remove the event listener in the unmount lifecycle, which is in React hooks and is available within the useEffect itself. So you have to do it like below with the return keyword. It will do the same thing as componentWillUnmount in class base components:

    React.useEffect(() => {
        if (isCardMoving) window.addEventListener("mousemove", handleCardMove);
        return () => window.removeEventListener("mousemove", handleCardMove);
    }, [isCardMoving]);
    

    Working Demo:

    CodeSandbox

    Update

    As @ZacharyHaber said in the comments, the main reason behind this behaviour is that your handleCardMove function will be redefined on every render, so to overcome this situation, we need to unbind the event from the window on each render with the useEffect callback. You can also make your initial code work using the useCallback approach. Still, you also need to add the previous useEffect callback to your component to make sure the event listener will remove in the component unmount cycle, it is a bit more coding, but this one will do the same as the above approach.

    const handleCardMove = React.useCallback((event) => {
       console.log({ x: event.offsetX, y: event.offsetY });
    }, []);
    
    React.useEffect(() => {
      if (isCardMoving) window.addEventListener("mousemove", handleCardMove);
      else window.removeEventListener("mousemove", handleCardMove);
      return () => window.removeEventListener("mousemove", handleCardMove);
    }, [isCardMoving, handleCardMove]);
    

    Working Demo:

    CodeSandbox