Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

React infinite rerender, useEffect issue, no set state triggered

My component seems to enter an endless loop and I cannot find the reason. I am using the useEffect and useState hooks but that is not it. To anyone wanting to tag this as duplicate: Please read the issue carefully. This endless re-render loop scenario only happens if:

  1. The mouse is moved too fast
  2. A user clicks anything inside the developer console and then triggers a mousemove event after (for the purpose of explenation clarity I've omitted the mousedown/mouseup handlers, but they are essentially the same as the mousemove handler, triggering a call to every subscribed callback.

Here is an example, but be careful it can cause an infinite loop and force you to close the browser tab Codesandbox: https://codesandbox.io/s/infiniteloopissue-lynhw?file=/index.js

I am using Map.forEach to trigger the setState of any subscribers on mousemove event. (You can see the relevant code below), and I am using useEffect to subscribe/unsubscribe from this "update event"

One thing that "fixes" the problem is the following - Check for a Reference Point comment (below in the Mouse Consumer). If the callback is removed as a dependency of the useCallback hook, everything works fine. But this is not good, because in this example, we obviously just extract the data into the state, but that callback function could be dependent on, say some other state, in which case it would not work. The callback needs to be mutable.

My guess is that the react somehow manages to re-render BEFORE the .forEach finishes it's iterations, in which case it would unsubscribe (thus removing the key), and re-subscribe (thus adding it again) triggering yet another callback, which then triggers another unsub/resub and we go into a loop. But that shouldn't be possible right? I mean javascrip is suppose to be blocking single threaded, how/why does react re-render in the middle of a forEach loop?

Also, does someone have a better idea on how to "subscribe" to a mousemove and run the callback. I recently saw some EventEmitter in some back-end code, but am not familiar with it. Am also not sure if that could fix the issue here, the issue being react takes precedence when updating over waiting for the main thread to finish a .forEach loop (at least I think that is it)

The base is simple:

App.js

const App = () => {
  return (
    <MouseProvider>
        <Component />
    </MouseProvider>
  )
}

Component.js

const Component = props => {
  const [mouse, setMouse] = useState({})

  const callback = data => {
    setMouse({ x: data.x, y: data.y })
  }

  useMouseTracker(callback)

  return (
    <div>
      {`x: ${mouse.x}   y: ${mouse.y}`}
    </div>
  )
}

The idea behind the component is, to write down the current mouse position on screen at all times. This information could be readily available in the context, however in order to render it on the screen, we need to trigger a "ReRender" so Context API is not a solution, instead.

Mouse Provider

//  Static mutable object used.
const mouseData = { x: 0, y: 0 }

//  A map of  "id : callback" pairs
const subscribers = new Map()

Provider = ({ children }) => {
  const mouseMoveHandler = useCallback(event => {
    if (event) {
      mouseData.x = event.clientX
      mouseData.y = event.clientY
      subscribers.forEach(callback => {
        callback({ ...mouseData})
      })
    }
  }, [])

  useEffect(() => {
    window.addEventListener('mousemove', mouseMoveHandler)
    return () => {
      window.removeEventListener('mousemove', mouseMoveHandler)
    }
  }, [mouseMoveHandler])

  return (
    <React.Fragment>
      {children}
    </React.Fragment>
  )
}

So every time the user moves his mouse, the mousemove handler will update the static object. The Provider component itself DOES NOT rerender.

Mouse Consumer

useMouseTracker = callback => {
  const id = 0 // This value is not 0, it's a system-wide per-component constant, guaranteed, tried and tested

  const subscribe = useCallback(() => {
    subscribers.set(id, callback)
  }, [id, callback /* Reference Point */])

  const unsubscribe = useCallback(() => {
    subscribers.delete(id)
  }, [id])

  useEffect(() => {
    subscribe()
    return unsubscribe
  }, [subscribe, unsubscribe])
}

As we can see, the Consumer Hook implements two functions, which subscribe and unsubscribe the id into the Map of callbacks, previously referenced in the Provider, that is ALL it does, it doesn't call the callback, it never triggers anything other then adding/removing the callback from the Map object. All of the "updating" is done by the Provider or rather the component who's callback the provider calls, on every mousemove. In other words, the useEffect doesn't EVER trigger a state update, mouse interaction does.

The useEffect hook returns an unsubscribe function which makes sure it "cleans up after itself" so that the callback doesn't get triggered if the component is dismounted.

The Problem

Well as I said the problem is, I end up in an endless re-render loop with this component, and it only happens if the mouse is moved too fast or if it goes "offscreen" such as into the developer console, for example.

EDIT: removed context entirely, was not necessary and was causing confustion. EDIT2: added codesandbox

like image 547
Dellirium Avatar asked Jun 04 '20 16:06

Dellirium


1 Answers

Split subscribe and unsubscribe to two different useEffect calls:

useEffect(() => {
    subscribe()
  }, [subscribe])

and

useEffect(() => unsubscribe, [unsubscribe])

This prevents the callback from being removed and recreated, but still cleans up if the component it unmounted. It does seem a bit kludgey, but it does seem to work.

According to the react docs:

The clean-up function runs before the component is removed from the UI to prevent memory leaks. Additionally, if a component renders multiple times (as they typically do), the previous effect is cleaned up before executing the next effect.

(e.g. when the effect dependencies change)

Edit sample sandbox

like image 198
Garrett Motzner Avatar answered Oct 24 '22 13:10

Garrett Motzner