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 useEffect
s 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