Home > OS >  How to manage asynchronous state updates when using event handlers in render method?
How to manage asynchronous state updates when using event handlers in render method?

Time:12-01

Let me explain the goal of my code first. I have a react component called "Tile" containing a sub-component called "TileMenu" which shows up when I make a right click on my Tile, calling the function "openMenu". I wanted to have two ways of closing it:

  • clicking somewhere else
  • waiting some time

But, I also wanted it to stay in place if the mouse was over it. So I needed a function to cancel the timer, which I called "keepMenuOpened". If I moved my mouse away, openMenu() was called again to relaunch the timer.

Here is my code:

import TileMenu from './TileMenu'

function Tile() {


  const [openedMenu, setOpenedMenu] = useState(false);
    // state used to display —or not— the TileMenu component
  const [timeoutID, setTimeoutID] = useState(null);
    // state to store timeout ID and clear it


  function openMenu() {
    // Actually open TileMenu
    setOpenedMenu(true);

    // Prepare TileMenu closing
    window.onclick = closeMenu;
      // first case: click somewhere else
    setTimeoutID(setTimeout(closeMenu, 3000));
      // second case: time out
    console.log('open', timeoutID);
  }

  function closeMenu() {
    setOpenedMenu(false);

    window.onclick = null;
    console.log('close', timeoutID);
    clearTimeout(timeoutID);
  }

  function keepMenuOpened() {
    console.log('keep', timeoutID);
    clearTimeout(timeoutID);
  }


  return(
    <>
      {openedMenu &&
      <TileMenu
        onMouseOver={keepMenuOpened} onMouseLeave={openMenu} // These two props are passed on to TileMenu component
      />}

      <textarea
        onContextMenu={openMenu}
      >
      </textarea>
    </>
  );
}

export default Tile

At first, it seemed to work perfectly. But I noticed that when I opened, then closed manually, and finally opened my TileMenu again, the delay it took to close a second time (this time alone) was calculated from the first time I opened it.

I used console.log() to see what was happening under the hood and it seemed to be caused by the asynchronous update of states in React (Indeed, at the first attempt, I get open null and close null in the console. When I move my mouse over the TileMenu and then leave it, I get for example open 53, then keep 89 and then open 89 !) If I understand well my specific case, React uses the previous state in openMenu and closeMenu but the current state in keepMenuOpened.

In fact, this is not my first attempt and before using a react state, "timeoutID" was a simple variable. But this time, it was inaccessible inside keepMenuOpened (it logged keep undefined in the console) even if declared in Tile() scope and accessible in openMenu and closeMenu. I think it's because closeMenu is called from openMenu. I found on the net it was called a closure but I didn't figure out exactly how it worked with React.

And now I haven't figured out how to solve my specific problem. I found that I could use useEffect() to access my updated states but it doesn't work in my case where I need to declare my functions inside Tile() to use them as event handlers. I wonder if my code is designed correctly.

CodePudding user response:

You need to clear previous timer when openMenu called.

function openMenu() {
  // clear previous timer before open
  clearTimeout(timeoutID);

  // Actually open TileMenu
  setOpenedMenu(true);

  // Prepare TileMenu closing
  window.onclick = closeMenu;

  // first case: click somewhere else
  setTimeoutID(setTimeout(closeMenu, 3000));
  // second case: time out
  console.log('open', timeoutID);
}

function closeMenu() {
  setOpenedMenu(false);

  window.onclick = null;
  console.log('close', timeoutID);

  // timer callback has executed, can remove this line
  clearTimeout(timeoutID);
}

CodePudding user response:

The issue here is that you don't reset when opening the menu.

You probably shouldn't store the timer id in state, it seems unnecessary. You also don't clear any running timeouts when the component unmounts, which can sometimes cause issues if you later enqueue state updates or other side-effects assuming the component is still mounted.

It's also considered improper to directly mutate the window.click property, you should add and remove event listeners.

You can use an useEffect hooks to handle both the clearing of the timeout and removing the window click event listener from the previous render cycle in a cleanup function when the openedMenu state updates. This will also clean up the listener and timer when the component unmounts.

function Tile() {
  const [openedMenu, setOpenedMenu] = useState(false);
  const timerIdRef = useRef();

  useEffect(() => {
    return () => {
      window.removeEventListener('click', closeMenu);
      clearTimeout(timerIdRef.current);
    }
  }, [openedMenu]);

  function openMenu() {
    setOpenedMenu(true);
    window.addEventListener('click', closeMenu);
    timerIdRef.current = setTimeout(closeMenu, 3000);
  }

  function closeMenu() {
    setOpenedMenu(false);
  }

  function keepMenuOpened() {
    clearTimeout(timerIdRef.current);
  }

  return(
    <>
      {openedMenu && (
        <TileMenu
          onMouseOver={keepMenuOpened}
          onMouseLeave={openMenu}
        />
      )}

      <textarea onContextMenu={openMenu} />
    </>
  );
}
  • Related