Search code examples
reactjstypescriptreact-hookspixi.jszustand

React + PIXI + Zustand - Stale state in mouse handlers


I have a pixi canvas with pointer handlers. You can click on a point on a ‘sequence’ and move it.

The mouse handlers had stale state. To fix it I am recreating the handlers whenever the sequence is modified, so that they always have fresh state. This fixed the state bug. But it doesn’t work if the state is modified on mouse up. Creating new handlers from a within a handler understandably preserves the stale state, even via a callback timer. So now all the bugs are fixed except that you can't drag a point twice :)

I am new to React and feel like I am going about this all wrong. Any suggestions?

This is how I get the sequence state:

const SequenceCanvas = React.forwardRef((props: any, ref) => {
    const canvasRef = useRef(null)

    const sequence: Sequence = useSequenceStore(state => { return state.sequence} )

Here is now I recreate the handlers:

const addHandlers = () : void => {
    console.log(`new mouse handlers: numsteps ${sequence.numSteps}`)
    parentContainer.removeAllListeners()
    parentContainer.on('pointerdown', (e: any) => {
        handlePointerDown(e)
    })
    parentContainer.on('dblclick', (e: any) => {
        handleDoubleClick(e)
    })
    parentContainer.on('pointerup', (e: any) => {
        handlePointerUp(e)
    })
    parentContainer.on('mouseupoutside', (e: any) => {
        handlePointerUp(e)
    })
    parentContainer.on('pointermove', (e: any) => {
        handlePointerMove(e)
    })
}

useEffect(() => {
    if (!mouseIsDown) {
        // Recreate mouse handlers each time the sequence changes. Otherwise the sequence state is stale
        addHandlers()
    }
}, [sequence]);

const handlePointerUp = (e: any) => {
    console.log(`handlePointerUp ${mouseIsDown}`)
    mouseIsDown = false;
    dragStepNum = -1
    isDraggingVertical = false;
    isDraggingHorizontal = false;

    setTimeout(() => {
        addHandlers()
    }, 100)
}

Here is an example of stale state. The sequence object here is stale unless I recreate the pointer handler each time the sequence changes.

const handlePointerMoveImp = (e: any) => {
    const sequence = useSequenceStore.getState().sequence

    if (mouseIsDown) {
        var x = e.data.global.x
        var y = e.data.global.y
        if (x !== null && y !== null && x !== Infinity && y !== Infinity) {
            // console.log(`drag ${JSON.stringify(e.data)}`)
            if (dragTarget === DragTargets.EnvelopePoint) {
                console.log(`handlePointerMoveImp - drag target is EnvelopePoint - tapPointNum ${tapPointNum}`)
                const midiOutputDeviceName : string = sequence.midiSettings.midiOutputDeviceName
                const midiChart:MidiChart = findMidiChart(midiOutputDeviceName, midiMaps)
                const envelope = findEnvelope(sequence.currentEnvelopeId);
                if (envelope) {
                    // const point = envelope.points[tapPointNum]
                    const controllerInfo: ControllerInfo = MidiDeviceDataService.getControllerInfo(midiChart, envelope.controller)
                    const time = Math.max(getXTime(sequence, x), 0)
                    const value: number = getYValue(sequence, y, controllerInfo.min, controllerInfo.max)
                    console.log(`handlePointerMoveImp - drag EnvelopePoint to ${time}/${value} - envelope <${sequence.currentEnvelopeId}>`)
                    moveEnvelopePoint(sequence.currentEnvelopeId, tapPointNum, time, value)
                }
            }

Solution

  • Stale React state in callback handlers is usually fixed by caching a copy of the current state into a React ref, and referencing the ref's current value in the callbacks. I'm not very familiar with zustand but I think something like the following should be very close to what you're after.

    const SequenceCanvas = React.forwardRef((props: any, ref) => {
      const canvasRef = useRef(null);
    
      const sequence: Sequence = useSequenceStore(state => state.sequence);
      const sequenceRef = useRef<Sequence>(sequence);
    
      useEffect(() => {
        sequenceRef.current = sequence;
      }, [sequence]);
    

    ...

      useEffect(() => {
        parentContainer.on('pointerdown', handlePointerDown);
        parentContainer.on('dblclick', handleDoubleClick);
        parentContainer.on('pointerup', handlePointerUp);
        parentContainer.on('mouseupoutside', handlePointerUp);
        parentContainer.on('pointermove', handlePointerMove);
    
        return () => {
          parentContainer.removeAllListeners()
        };
      }, []);
    

    ...

      const handlePointerUp = (e: any) => {
        console.log(`handlePointerUp ${mouseIsDown}`)
        mouseIsDown = false;
        dragStepNum = -1
        isDraggingVertical = false;
        isDraggingHorizontal = false;
      }
    

    ...

      const handlePointerMoveImp = (e: any) => {
        const sequence = sequenceRef.current;
    
        if (mouseIsDown) {
          const { x, y } = e.data.global.x;
    
          if (x !== null && y !== null && x !== Infinity && y !== Infinity) {
            if (dragTarget === DragTargets.EnvelopePoint) {
              const midiOutputDeviceName : string = sequence.midiSettings.midiOutputDeviceName
              const midiChart:MidiChart = findMidiChart(midiOutputDeviceName, midiMaps)
              const envelope = findEnvelope(sequence.currentEnvelopeId);
              if (envelope) {
                const controllerInfo: ControllerInfo = MidiDeviceDataService.getControllerInfo(midiChart, envelope.controller)
                const time = Math.max(getXTime(sequence, x), 0)
                const value: number = getYValue(sequence, y, controllerInfo.min, controllerInfo.max)
                moveEnvelopePoint(sequence.currentEnvelopeId, tapPointNum, time, value)
              }
            }
          }
        }
      }