touch イベントと mouse イベントの両方に対応したいとき、touchstart 内で preventDefault を呼び出すというテクニックがあります。こうすると、touchstart、touchend のみが発火してそのあとのmousedown、mouseup、click が発火しなくなり、touch イベントと mouse イベントをそれぞれ独立して設定ができます。
しかし、React では事情があって onTouchStart のなかで preventDefault を呼び出すことができません (後述)。そのため、特殊なやり方をする必要があります。
結論
以下の usePreventDefault を使うことで、touchstart でも preventDefault を呼び出すことができます。
import { useEffect, useRef } from 'react'; export const usePreventDefault = <T extends HTMLElement>( eventName: string, enable = true ) => { const ref = useRef<T>(null); useEffect(() => { const current = ref.current; if (!current) { return; } const handler = (event: Event) => { if (enable) { event.preventDefault(); } }; current.addEventListener(eventName, handler); return () => { current.removeEventListener(eventName, handler); }; }, [enable, eventName]); return ref; };
このように使います。
const App = () => { const ref = usePreventDefault<HTMLDivElement>('touchstart'); return ( <div ref={ref} onTouchStart={() => {}} onTouchEnd={() => {}} onMouseDown={() => {}} onMouseUp={() => {}} onClick={() => {}} >click me</div> ); };
usePreventDefault の内部で touchstart 時に preventDefault を呼び出すように設定したので、あとは好きなようにできます。タッチしたあとに mousedown が発火することもありません。
そもそもなぜ React では touchstart 内で preventDefault ができないのか
愚直に書くとしたら、次のようになるはずです。しかし、これでは動きません。
<div onTouchStart={event => event.preventDefault()}>
「Unable to preventDefault inside passive event listener invocation.」というエラーが出ます。passive なイベントリスナーというのは、かんたんに言えば「preventDefaultは呼び出さないよ」と宣言したイベントリスナーです。呼び出さないと宣言しているのに preventDefault を呼び出しているので怒られているというわけです。
では、なぜ React は touchstart イベントハンドラーを passive で登録しているのでしょうか?実はこれはブラウザの仕様と関係しています。
React ではイベントリスナーを各要素につけるのではなく、documentレベルにイベントリスナーをまとめてアタッチしています*1。Chrome などには document レベルに貼られた touchstart イベントは自動的に passive なイベントリスナーとして扱われるという制約があるため(参照)、React が document ルートにはりつけた onTouchStart は passive として登録されます*2。そのため preventDefault を呼び出せないのです。
解決方法
解決方法はかんたんで、passive でない形で自前でイベントリスナーを貼るだけです。
const App = () => { const ref = useRef<HTMLDivElement>(null); useEffect(() => { const current = ref.current; if (!current) return; const onTouchStart = (event: Event) => event.preventDefault(); current.addEventListener('touchstart', onTouchStart); return () => { current.removeEventListener('touchstart', onTouchStart); }; }, []); return <div ref={ref}>click me</div>; };
これで touchstart のデフォルト動作を止めることができたので、あとは React の onTouchStart を自由に使えます。
カスタムフックに抜き出す
上のままでもいいですが、カスタムフックに抜き出すとよりわかりやすくなります。
import { useEffect, useRef } from 'react'; export const usePreventDefault = <T extends HTMLElement>( eventName: string, enable = true ) => { const ref = useRef<T>(null); useEffect(() => { const current = ref.current; if (!current) { return; } const handler = (event: Event) => { if (enable) { event.preventDefault(); } }; current.addEventListener(eventName, handler); return () => { current.removeEventListener(eventName, handler); }; }, [enable, eventName]); return ref; };