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 mediv
);
};
usePreventDefault の内部で touchstart 時に preventDefault を呼び出すように設定したので、あとは好きなようにできます。タッチしたあとに mousedown が発火することもありません。
そもそもなぜ React では touchstart 内で preventDefault ができないのか
愚直に書くとしたら、次のようになるはずです。しかし、これでは動きません。
<div onTouchStart={event => event.preventDefault()}>
demo
「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 mediv;
};
これで 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;
};