Home > Software design >  Is there any better way to avoid stale closures in React hooks?
Is there any better way to avoid stale closures in React hooks?

Time:01-01

Let's say we have these requirements to implement:

  1. Display the cursor position as the mouse moves while being held down.
  2. Next time the mouse is down, log to the console the last cursor position when the mouse was previously released.

Here is my solution and it works fine. But I'm not quite happy with it: https://codesandbox.io/s/funny-orla-kdx19?file=/src/App.js

import React, { useState, useEffect } from "react";
import "./styles.css";

const html = document.documentElement;

export default function App() {
  const [cursorPos, setCursorPos] = useState({});

  useEffect(() => {
    const pointerMove = (e) => {
      setCursorPos({ x: e.clientX, y: e.clientY });
    };
    const pointerUp = (e) => {
      html.removeEventListener("pointermove", pointerMove);
      html.removeEventListener("pointerup", pointerUp);
    };
    const pointerDown = () => {
      console.log("Last position when pointer up", cursorPos);
      html.addEventListener("pointermove", pointerMove);
      html.addEventListener("pointerup", pointerUp);
    };
    console.log("registering pointerdown callback");
    html.addEventListener("pointerdown", pointerDown);
    return () => {
      html.removeEventListener("pointerdown", pointerDown);
    };
  }, [cursorPos]);

  return (
    <div className="App">
      Tracked mouse position: ({cursorPos.x}, {cursorPos.y})
    </div>
  );
}

As you can see, the cursorPos dependency is crucial. Without it, the pointerDown listener won't read the latest tracked cursor position. However, it causes the effect callback to be called so often since the cursorPos is updated by the pointermove event. And, the pointerdown listener is deregister/register as a result.

This frequent reregistering of the listener may not pose a practical problem. But it's not conceptually a neat way of doing things. It's an overhead to avoid if possible. That's why I'm not satisfied.

I actually come up with some alternatives which didn't make me fulfilled, either.

Solution 1:

https://codesandbox.io/s/mystifying-lamport-nr3jk?file=/src/App.js

We can log down the cursor position every time the mouse is up and store it in another state variable (cursorPosWhenMouseUp) that is to be read by the next mouse down event. Since the mouse up event is not that frequently triggered, the reregistering frequency is acceptable. The downside of this solution is that the cursorPosWhenMouseUp variable is redundant to my eye because the last cursorPos value should be the same. Its sole purpose is to mitigate a technical issue. It's not elegant to let it stick around in the code.

Solution 2:

https://codesandbox.io/s/upbeat-shamir-rhjs4?file=/src/App.js

We can store another copy of cursorPos in a ref from useRef()(e.g. cursorPosRef). Since ref is stable across rendering and no local variable involved, we can rest assured to safely read it from the pointerDown callback. This solution has similar issues to Solution 1 because we have to attentively make sure the cursorPosRef is all the time mirroring the cursorPos value.

Solution 3:

https://codesandbox.io/s/polished-river-psvz7?file=/src/App.js

In regard to the redundancy issue presented above, we can abandon the cursorPos state variable and only use cursorPosRef. We can directly read it in the JSX. We just need a way to force update the component from the pointermove callback. React Hooks FAQ sheds light on this. The problem with this solution is that the forceUpdate stuff is not welcome in the React methodology.

So, is there any better way to achieve these requirements?

CodePudding user response:

I don't see the need of passing cursorPos as dependency, here is my try:

import React, { useEffect, useState } from 'react';
import './styles.css';

const html = document.documentElement;

export default function App() {
  const [cursorPos, setCursorPos] = useState({});
  const pointerMove = e => {
    setCursorPos({ x: e.clientX, y: e.clientY });
  };

  useEffect(() => {
    html.addEventListener('pointerdown', e => {
      html.addEventListener('pointermove', pointerMove);
    });
    html.addEventListener('pointerup', e => {
      html.removeEventListener('pointermove', pointerMove);
    });
    return () => {
      html.removeEventListener('pointerdown');
      html.removeEventListener('pointerup');
    };
  }, []);

  return (
    <div className="App">
      Tracked mouse position: ({cursorPos.x}, {cursorPos.y})
    </div>
  );
}

https://codesandbox.io/s/eloquent-mendeleev-wqzk4?file=/src/App.js

UPDATE:

To keep track of the previous position I would use useRef:

import React, { useEffect, useRef, useState } from 'react';

const html = document.documentElement;

export default function Test() {
  const [cursorCurrentPos, setCursorCurrentPos] = useState({});
  const cursorPreviousPos = useRef({});

  const pointerMove = e => {
    setCursorCurrentPos({ x: e.clientX, y: e.clientY });
  };

  useEffect(() => {
    html.addEventListener('pointerdown', e => {
      console.log('Last position when pointer up', cursorPreviousPos.current);
      html.addEventListener('pointermove', pointerMove);
    });
    html.addEventListener('pointerup', e => {
      cursorPreviousPos.current = { x: e.clientX, y: e.clientY };
      html.removeEventListener('pointermove', pointerMove);
    });
    return () => {
      html.removeEventListener('pointerdown');
      html.removeEventListener('pointerup');
    };
  }, []);

  return (
    <div className="App">
      Tracked mouse position: ({cursorCurrentPos.x}, {cursorCurrentPos.y})
    </div>
  );
}
  • Related