Search code examples
reactjsreact-hooksreact-functional-componentreact-class-based-component

Limiting addEventListener to componentDidMount using useEffect hook


I have a class-based component which uses multitouch to add child nodes to an svg and this works well. Now, I am trying to update it to use a functional component with hooks, if for no other reason than to better understand them.

In order to stop the browser using the touch events for gestures, I need to preventDefault on them which requires them to not be passive and, because of the lack of exposure of the passive configuration within synthetic react events I've needed to use svgRef.current.addEventListener('touchstart', handler, {passive: false}). I do this in the componentDidMount() lifecycle hook and clear it in the componentWillUnmount() hook within the class.

When I translate this to a functional component with hooks, I end up with the following:

export default function Board(props) {

    const [touchPoints, setTouchPoints] = useState([]);
    const svg = useRef();

    useEffect(() => {
        console.log('add touch start');
        svg.current.addEventListener('touchstart', handleTouchStart, { passive: false });

        return () => {
            console.log('remove touch start');
            svg.current.removeEventListener('touchstart', handleTouchStart, { passive: false });
        }
    });

    useEffect(() => {
        console.log('add touch move');
        svg.current.addEventListener('touchmove', handleTouchMove, { passive: false });

        return () => {
            console.log('remove touch move');
            svg.current.removeEventListener('touchmove', handleTouchMove, { passive: false });
        }
    });

    useEffect(() => {
        console.log('add touch end');
        svg.current.addEventListener('touchcancel', handleTouchEnd, { passive: false });
        svg.current.addEventListener('touchend', handleTouchEnd, { passive: false });

        return () => {
            console.log('remove touch end');
            svg.current.removeEventListener('touchend', handleTouchEnd, { passive: false });
            svg.current.removeEventListener('touchcancel', handleTouchEnd, { passive: false });
        }
    });


    const handleTouchStart = useCallback((e) => {
        e.preventDefault();

        // copy the state, mutate it, re-apply it
        const tp = touchPoints.slice();

        // note e.changedTouches is a TouchList not an array
        // so we can't map over it
        for (var i = 0; i < e.changedTouches.length; i++) {
            const touch = e.changedTouches[i];
            tp.push(touch);
        }
        setTouchPoints(tp);
    }, [touchPoints, setTouchPoints]);

    const handleTouchMove = useCallback((e) => {
        e.preventDefault();

        const tp = touchPoints.slice();

        for (var i = 0; i < e.changedTouches.length; i++) {
            const touch = e.changedTouches[i];
            // call helper function to get the Id of the touch
            const index = getTouchIndexById(tp, touch);
            if (index < 0) continue;
            tp[index] = touch;
        }

        setTouchPoints(tp);
    }, [touchPoints, setTouchPoints]);

    const handleTouchEnd = useCallback((e) => {
        e.preventDefault();

        const tp = touchPoints.slice();

        for (var i = 0; i < e.changedTouches.length; i++) {
            const touch = e.changedTouches[i];
            const index = getTouchIndexById(tp, touch);
            tp.splice(index, 1);

        }

        setTouchPoints(tp);
    }, [touchPoints, setTouchPoints]);

    return (
        <svg 
            xmlns={ vars.SVG_NS }
            width={ window.innerWidth }
            height={ window.innerHeight }
        >
            { 
                touchPoints.map(touchpoint =>
                    <TouchCircle 
                        ref={ svg }
                        key={ touchpoint.identifier }
                        cx={ touchpoint.pageX }
                        cy={ touchpoint.pageY }
                        colour={ generateColour() }
                    />
                )
            }
        </svg>
    );
}

The issue this raises is that every time there is a render update, the event listeners all get removed and re-added. This causes the handleTouchEnd to be removed before it has a chance to clear up added touches among other oddities. I'm also finding that the touch events aren't working unless i use a gesture to get out of the browser which triggers an update, removing the existing listeners and adding a fresh set.

I've attempted to use the dependency list in useEffect and I have seen several people referencing useCallback and useRef but I haven't been able to make this work any better (ie, the logs for removing and then re-adding the event listeners still all fire on every update).

Is there a way to make the useEffect only fire once on mount and then clean up on unmount or should i abandon hooks for this component and stick with the class based one which is working well?

Edit

I've also tried moving each event listener into its own useEffect and get the following console logs:

remove touch start
remove touch move
remove touch end
add touch start
add touch move
add touch end

Edit 2

A couple of people have suggested adding a dependency array which I've tried like this:

    useEffect(() => {
        console.log('add touch start');
        svg.current.addEventListener('touchstart', handleTouchStart, { passive: false });

        return () => {
            console.log('remove touch start');
            svg.current.removeEventListener('touchstart', handleTouchStart, { passive: false });
        }
    }, [handleTouchStart]);

    useEffect(() => {
        console.log('add touch move');
        svg.current.addEventListener('touchmove', handleTouchMove, { passive: false });

        return () => {
            console.log('remove touch move');
            svg.current.removeEventListener('touchmove', handleTouchMove, { passive: false });
        }
    }, [handleTouchMove]);

    useEffect(() => {
        console.log('add touch end');
        svg.current.addEventListener('touchcancel', handleTouchEnd, { passive: false });
        svg.current.addEventListener('touchend', handleTouchEnd, { passive: false });

        return () => {
            console.log('remove touch end');
            svg.current.removeEventListener('touchend', handleTouchEnd, { passive: false });
            svg.current.removeEventListener('touchcancel', handleTouchEnd, { passive: false });
        }
    }, [handleTouchEnd]);

but I'm still receiving a log to say that each of the useEffects have been removed and then re-added on each update (so every touchstart, touchmove or touchend which causes a paint - which is a lot :) )

Edit 3

I've replaced window.(add/remove)EventListener with useRef()

ta


Solution

  • Thanks a lot guys - we got to the bottom of it (w00t)

    In order to stop the component useEffect hook firing multiple times, it is required to supply an empty dependency array to the hook (as suggested by Son Nguyen and wentjun) however this meant that the current touchPoints state was not accessible within the handlers.

    The answer (suggested by wentjun) was in How to fix missing dependency warning when using useEffect React Hook?

    which mentions the hooks faq: https://reactjs.org/docs/hooks-faq.html#what-can-i-do-if-my-effect-dependencies-change-too-often

    this is how my component ended up

    export default function Board(props) {
        const [touchPoints, setTouchPoints] = useState([]);
        const svg = useRef();
    
        useEffect(() => {
            // required for the return value
            const svgRef = svg.current;
    
            const handleTouchStart = (e) => {
                e.preventDefault();
    
                // use functional version of mutator
                setTouchPoints(tp => {
                    // duplicate array
                    tp = tp.slice();
    
                    // note e.changedTouches is a TouchList not an array
                    // so we can't map over it
                    for (var i = 0; i < e.changedTouches.length; i++) {
                        const touch = e.changedTouches[i];
                        const angle = getAngleFromCenter(touch.pageX, touch.pageY);
    
                        tp.push({ touch, angle });
                    }
    
                    return tp;
                });
            };
    
            const handleTouchMove = (e) => {
                e.preventDefault();
    
                setTouchPoints(tp => {
                    tp = tp.slice();
    
                    // move existing TouchCircle with same key
                    for (var i = 0; i < e.changedTouches.length; i++) {
                        const touch = e.changedTouches[i];
                        const index = getTouchIndexById(tp, touch);
                        if (index < 0) continue;
                        tp[index].touch = touch;
                        tp[index].angle = getAngleFromCenter(touch.pageX, touch.pageY);
                    }
    
                    return tp;
                });
            };
    
            const handleTouchEnd = (e) => {
                e.preventDefault();
    
                setTouchPoints(tp => {
                    tp = tp.slice();
    
                    // delete existing TouchCircle with same key
                    for (var i = 0; i < e.changedTouches.length; i++) {
                        const touch = e.changedTouches[i];
                        const index = getTouchIndexById(tp, touch);
                        if (index < 0) continue;
                        tp.splice(index, 1);
                    }
    
                    return tp;
                });
            };
    
            console.log('add touch listeners'); // only fires once
            svgRef.addEventListener('touchstart', handleTouchStart, { passive: false });
            svgRef.addEventListener('touchmove', handleTouchMove, { passive: false });
            svgRef.addEventListener('touchcancel', handleTouchEnd, { passive: false });
            svgRef.addEventListener('touchend', handleTouchEnd, { passive: false });
    
            return () => {
                console.log('remove touch listeners');
                svgRef.removeEventListener('touchstart', handleTouchStart, { passive: false });
                svgRef.removeEventListener('touchmove', handleTouchMove, { passive: false });
                svgRef.removeEventListener('touchend', handleTouchEnd, { passive: false });
                svgRef.removeEventListener('touchcancel', handleTouchEnd, { passive: false });
            }
        }, [setTouchPoints]);
    
        return (
            <svg 
                ref={ svg }
                xmlns={ vars.SVG_NS }
                width={ window.innerWidth }
                height={ window.innerHeight }
            >
                { 
                    touchPoints.map(touchpoint =>
                        <TouchCircle 
                            key={ touchpoint.touch.identifier }
                            cx={ touchpoint.touch.pageX }
                            cy={ touchpoint.touch.pageY }
                            colour={ generateColour() }
                        />
                    )
                }
            </svg>
        );
    }
    
    

    Note: I added setTouchPoints to the dependency list to be more declarative

    Mondo respect guys

    ;oB