Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

React で touchstart で preventDefault したいとき

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()}>

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 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;
};

*1:v17からは React ツリーをレンダーしようとしているルート DOM コンテナにアタッチします

*2:v17からはドキュメントルートではなくなりましたが、breaking changes を最小限にとどめるために、明示的に passive を指定してあります