Stale state inside an event handler

48 views Asked by At

I have a JS library (Cytoscape) inside my React component. The library need to be handled events with set event handler functions (on diagram/ component initilazation).

The problem is that inside these events the state is stale - it only gets the first state because of the closure.

 const [otherState, setOtherState] = useState()
 const handleMouseOverNode = (e) => {
    // this only gets the initial value but doesn't update when state changes
    console.log (otherState)
 }
 useEffect(() => {
        cyRef.current.on('mouseover', 'node', handleMouseOverNode);
    }
    return () => {
        cyRef.current.removeListener('mouseover');
    };
 }, [cyRef.current]);

How do I get the real (non stale) state ?

2

There are 2 answers

1
Ori Drori On BEST ANSWER

The experimental useEffectEvent would solve this problem eventually. You can currently use it, but it's not stable, and might break in the future (see Declaring an Effect Event):

import { experimental_useEffectEvent as useEffectEvent } from 'react';

const [otherState, setOtherState] = useState()

// wrap with useEffectEvent
const handleMouseOverNode = useEffectEvent((e) => {
  console.log(otherState)
})

useEffect(() => {
  cyRef.current.on('mouseover', 'node', handleMouseOverNode);

  return () => {
    cyRef.current.removeListener('mouseover');
  };
}, [cyRef.current]); // no need to add handleMouseOverNode as a dep

Currently the clunky solution is to use another ref to hold the updated value:

const [otherState, setOtherState] = useState()

const otherStateRef = useRef(otherState) // create a ref

// always update the ref with current value
useEffect(() => {
  otherStateRef.current = otherState
})

useEffect(() => {
  const handleMouseOverNode = (e) => {
    console.log(otherStateRef.current) // get the value from the ref
  }
  
  cyRef.current.on('mouseover', 'node', handleMouseOverNode);

  return () => {
    cyRef.current.removeListener('mouseover');
  };
}, [cyRef.current]);
2
Nick Parsons On

Your effect has a dependency on the handleMouseOverNode as it uses that function within it, so you should provide handleMouseOverNode as a dependency in the dependency array:

const [otherState, setOtherState] = useState()
const handleMouseOverNode = (e) => {
  // should now be the latest state value
  console.log (otherState);
}
useEffect(() => {
  cyRef.current.on('mouseover', 'node', handleMouseOverNode);

  return () => {
    cyRef.current.removeListener('mouseover');
  };
}, [cyRef.current, handleMouseOverNode]);

With this approach, your useEffect will recreate the event listener on every rerender, giving it access to call the newest handleMouseOverNode function that has access to the latest state. I suggest that you optimize this though, as a new function reference will be created on every rerender with the above approach. Instead, you can memoize the callback function so that it's only recreated (and thus your even listeners are only re-added) when your function state changes by using useCallback():

const handleMouseOverNode = useCallback((e) => {
  // this only gets the initial value but doesn't update when state changes
  console.log (otherState)
}, [otherState]);

And then pass handleMouseOverNode as a dependency to the useEffect() as done above.