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?
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
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 :) )
I've replaced window.(add/remove)EventListener
with useRef()
ta
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