Cormor
Cormor

Reputation: 69

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

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:

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.

Upvotes: 2

Views: 83

Answers (2)

Drew Reese
Drew Reese

Reputation: 202686

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 in a cleanup function when the component unmounts.

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

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

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

  function closeMenu() {
    setOpenedMenu(false);
    window.removeEventListener('click', closeMenu);
    clearTimeout(timerIdRef.current);
  }

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

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

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

Upvotes: 1

fixiabis
fixiabis

Reputation: 373

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);
}

Upvotes: 0

Related Questions