0

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

wentjun
  • 40,384
  • 10
  • 95
  • 107
obie
  • 578
  • 7
  • 23
  • 1
    You can make useEffect() fire only once by passing an empty array as second argument. If its not a one-off action you continue optimize by adding more and more value to the array to fine-tune when React should fire it. – Son Nguyen Jun 25 '20 at 09:52
  • added an update - tried adding a dependency array including the handlers but i'm still getting constant updates – obie Jun 25 '20 at 10:12
  • 1
    useEffect() can return a function to call once at the end (sort of ComponentWillUnmount) I guess its the right place you use your removeListeners – Son Nguyen Jun 25 '20 at 10:32
  • Thanks for the response - I'm already using the return function for each of the useEffect hooks and this gets called on every paint / update before the listeners are added again (see log in edit 2) – obie Jun 25 '20 at 10:37
  • your code fired more than once even with an empty dependency array? – Son Nguyen Jun 25 '20 at 10:41
  • actually now you mention it, no - which is great. However now I get another side effect where the handlers cannot change the touchPoints array - there is no error, but the touchPoints array doesn't get updated when setTouchPoints(tp) is called in the handler. In order to affect the state, I need to include both the `touchPoints` array and the `setTouchPoints` function as dependencies – obie Jun 25 '20 at 13:01
  • I've added an answer - not sure how to credit you properly – obie Jun 25 '20 at 14:55

2 Answers2

3

If you only want this to happen when the component is mounted and unmounted, you will need to supply the useEffect hook with an empty array as the dependency array.

useEffect(() => {
    console.log('adding event listeners');
    window.addEventListener('touchstart', handleTouchStart, { passive: false });
    window.addEventListener('touchend', handleTouchEnd, { passive: false });
    window.addEventListener('touchcancel', handleTouchEnd, { passive: false });
    window.addEventListener('touchmove', handleTouchMove, { passive: false });

    return () => {
        console.log('removing event listeners');
        window.removeEventListener('touchstart', handleTouchStart, { passive: false });
        window.removeEventListener('touchend', handleTouchEnd, { passive: false });
        window.removeEventListener('touchcancel', handleTouchEnd, { passive: false });
        window.removeEventListener('touchmove', handleTouchMove, { passive: false });
    }
}, []);
wentjun
  • 40,384
  • 10
  • 95
  • 107
  • if I try this i get the following: "Line 67:8: React Hook useEffect has a missing dependency: 'handleTouchStart'. Either include it or remove the dependency array react-hooks/exhaustive-deps" for each useEffect (they've been split into separate useEffect calls now) – obie Jun 25 '20 at 10:00
  • added an update - tried the dependency array but i'm still getting near constant updates – obie Jun 25 '20 at 10:18
  • 1
    You can consider disabling that particular eslint rule, or you can follow the solutions in this post: https://stackoverflow.com/questions/55840294/how-to-fix-missing-dependency-warning-when-using-useeffect-react-hook – wentjun Jun 25 '20 at 10:39
  • 1
    Also, you might want to remove `touchPoints` from `[touchPoints, setTouchPoints]` – wentjun Jun 25 '20 at 10:40
  • touchPoints is a dependency as its copied, updated and then re-set with setTouchPoints – obie Jun 25 '20 at 11:39
  • I have tried disabling the eslint rule but I end up with some really interesting side effects - the `setTouchPoints` function is called but doesn't actually update the touchPoints state hook seeming to indicate that it doesn't have a reference to the original state hook – obie Jun 25 '20 at 11:41
  • I've added an answer which i would never have got to without your input - thanks so much – obie Jun 25 '20 at 14:55
  • 1
    @obie ahh!! Nice! – wentjun Jun 25 '20 at 15:45
1

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

obie
  • 578
  • 7
  • 23