🖱

React のダブルクリック(多重クリック)対策一覧

2024/09/22に公開
7

概要

React アプリにおけるダブルクリック(多重クリック)対策の実装とそのデモを一覧でまとめてみました。コーディングのヒントになれば幸いです。

※本記事で紹介する実装は React SPA アプリを想定し、ボタンに対するダブルクリックに焦点を当てています。

ダブルクリックの問題点

ダブルクリックで問題となるのがクリックアクションに紐づく API 呼び出しが複数回行われてしまうことです。
API 側で問題にならないよう設計されてれば安心ですが(トークンを利用するなど)、そうでなかったりそもそも API 側で対策すること自体が難しい場合もあるかもしれません。

今回は TODO アプリのタスク追加機能を想定します。
タスク名を入力し「追加」ボタンをクリックすると、タスクの作成処理(時間のかかる API 呼び出しを想定してください)が実行されタスクが追加されます。
ユーザーが「追加」ボタンをすばやく 2 回クリックすると、タスクの作成処理が 2 回行われてしまいます。

ダブルクリックの問題点
ダブルクリックの問題点

TodoApp.tsx
export const TodoApp = () => {
  ...

  // タスク作成処理
  const createTodo = async () => {
    try {
      await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
      ...
    } catch {
      alert("エラーが発生しました");
    }
  };

  return (
   ...
      {/* タスク入力欄 */}
      <Input
        value={name}
        onChange={(event) => setName(event.currentTarget.value)}
      />
      <Button
        disabled={!name}
        onClick={createTodo}
      >
         追加
      </Button>
   ...
  );
};
デモ

対策一覧

useState フックを利用して処理中状態を管理する

useState フックを利用してタスク作成処理の実行中状態を管理し、実行中は「追加」ボタンを disabled (もしくは loading)に変更します。

useState フックを利用し処理中状態を管理する

TodoApp.tsx
export const TodoApp = () => {
  ...
+ const [isCreating, setIsCreating] = useState(false);

  // タスク作成処理
  const createTodo = async () => {
+   setIsCreating(true);
    try {
      await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
      ...
    } catch {
      alert("エラーが発生しました");
    }
+   setIsCreating(false);
  };

  return (
   ...
      {/* タスク入力欄 */}
      <Input
        value={name}
        onChange={(event) => setName(event.currentTarget.value)}
      />
      <Button
        disabled={!name}
+       loading={isCreating}
        onClick={createTodo}
      >
         追加
      </Button>
   ...
  );
};
デモ

useRef フックを利用して処理中状態を記録する

useRef フックを利用してタスク作成処理の実行中状態を記録しておき、完了するまで次回以降の実行をスキップします。useRef 値の変更は再レンダリングを行わないので、UI を変更することはできません。実行処理がすぐに完了するようなパターンで利用するのがよさそうです。

useRef フックを利用して処理中状態を記録する

TodoApp.tsx
export const TodoApp = () => {
  ...
+ const isCreatingRef = useRef(false);

  // タスク作成処理
  const createTodo = async () => {
+   if (isCreatingRef.current) return; // 処理中はスキップ
+
+   isCreatingRef.current = true;
    try {
      await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
      ...
    } catch {
      alert("エラーが発生しました");
    }
+   isCreatingRef.current = false;
  };

  return (
   ...
      {/* タスク入力欄 */}
      <Input
        value={name}
        onChange={(event) => setName(event.currentTarget.value)}
      />
      <Button
        disabled={!name}
        onClick={createTodo}
      >
         追加
      </Button>
   ...
  );
};
デモ

ボタンの DOM を直接 disabled に変更する

タスク作成処理の実行中に「追加」ボタンの DOM を直接 disabled に変更します。
React の仮想 DOM を通さないので思わぬバグを生むかもしれません。ただ、直接 DOM を変更してるためどのやり方よりも最速でボタンを押せなくさせることができます。こんなやり方もあるよと参考程度に留めるのがよさそうです。

ボタンの DOM を直接 disabled に変更する

TodoApp.tsx
export const TodoApp = () => {
  ...

  // タスク作成処理
- const createTodo = async () => {
+ const createTodo: MouseEventHandler<HTMLButtonElement> = async (e) => {
+   e.currentTarget.disabled = true;
    try {
      await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
      ...
    } catch {
      alert("エラーが発生しました");
    }
+   e.currentTarget.disabled = false;
  };

  return (
   ...
      {/* タスク入力欄 */}
      <Input
        value={name}
        onChange={(event) => setName(event.currentTarget.value)}
      />
      <Button
        disabled={!name}
        onClick={createTodo}
      >
         追加
      </Button>
   ...
  );
};
デモ

画面全体にローダーを表示させ操作できなくさせる

タスク作成処理の実行中に画面全体にローダーを表示させユーザーに操作できなくさせます。
ローダー表示中はボタンだけでなくフォームへの入力もできなくなります。問い合わせフォームなどの入力欄の多い画面で利用するとよさそうです。

画面全体にローダーを表示させ操作できなくさせる

※ 下記は実装イメージです。実際のコードはデモを確認ください。

TodoApp.tsx
+// 画面全体にローダーを出すグローバルなコンポーネント(子コンポーネントならどこでも利用できるようにプロバイダー形式)
+ const LoadingOverlayProvider = ({ children }: { children: ReactNode }) => {
+ ...
+};

-export const TodoApp = () => (
+export const TodoAppContent = () => {
  ...
+ const { setIsShowLoadingOverlay } = useContext(LoadingOverlayContext);

  // タスク作成処理
  const createTodo = async () => {
+   setIsShowLoadingOverlay(true);
    try {
      await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
      ...
    } catch {
      alert("エラーが発生しました");
    }
+   setIsShowLoadingOverlay(false);
  };

  return (
   ...
      {/* タスク入力欄 */}
      <Input
        value={name}
        onChange={(event) => setName(event.currentTarget.value)}
      />
      <Button
        disabled={!name}
        onClick={createTodo}
      >
         追加
      </Button>
   ...
  );
};

+export const TodoApp = () => (
+ <LoadingOverlayProvider>
+   <TodoApp />
+ </LoadingOverlayProvider>
+);

デモはReact コンテクストを利用するパターンとグローバルステート(jotai)を利用するパターンの 2 例用意しました。

デモ(React コンテクスト)
デモ(グローバルステート)

ボタンのクリックイベントをデバウンスする

タスク作成処理をデバウンスして実行を 1 つにまとめます。
UI を変更したりはしないので、実行処理がすぐに完了するようなパターンで利用するのがよさそうです。

デバウンスとは?

Delay function calls until a set time elapses after the last invocation
最後の呼び出しから一定時間経過するまで関数呼び出しを遅延させる

debounce npm パッケージのリポジトリより引用

ボタンのクリックイベントをデバウンスする

TodoApp.tsx
+// debounce パッケージをインストールしておく
+// $ npm install debounce

export const TodoApp = () => {
  ...

  // タスク作成処理
- const createTodo = async () => {
+ const createTodo = debounce(async () => {
    try {
      await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
      ...
    } catch {
      alert("エラーが発生しました");
    }
- }
+ }, 500);

  return (
   ...
      {/* タスク入力欄 */}
      <Input
        value={name}
        onChange={(event) => setName(event.currentTarget.value)}
      />
      <Button
        disabled={!name}
        onClick={createTodo}
      >
         追加
      </Button>
   ...
  );
};
デモ

フォームライブラリを利用する

タスク作成処理をフォームライブラリ経由で実行し、実行中は「追加」ボタンを disabled (もしくは loading)に変更します。
今回はReact Hook Formを利用しました。

フォームライブラリを利用する

TodoApp.tsx
+// react-hook-form パッケージをインストールしておく
+// $ npm install react-hook-form

export const TodoApp = () => {
  ...

+ const {
+   register,
+   handleSubmit,
+   reset,
+   formState: { isValid, isSubmitting: isCreating },
+  } = useForm<{ name: string }>();

  // タスク作成処理
- const createTodo = async () => {
+ const createTodo = async ({ name }: { name: string }) => {
    try {
      await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
      ...
    } catch {
      alert("エラーが発生しました");
    }
  }

  return (
   ...
      {/* タスク入力欄 */}
+     <form onSubmit={handleSubmit(createTodo)}>
        <Input
-         value={name}
-         onChange={(event) => setName(event.currentTarget.value)}
+         {...register("name", { required: true })}
        />
        <Button
-         disabled={!name}
-         onClick={createTodo}
+         type="submit"
+         disabled={!isValid}
+         loading={isCreating}
        >
          追加
        </Button>
+     </form>
   ...
  );
};
デモ

データフェッチライブラリを利用する

タスク作成処理をデータフェッチライブラリ経由で実行し、実行中は「追加」ボタンを disabled (もしくは loading)に変更します。
今回はSWRを利用しました。

データフェッチライブラリを利用する

TodoApp.tsx
+// swr パッケージをインストールしておく
+// $ npm install swr

export const TodoApp = () => {
  ...

  // タスク作成処理
-  const createTodo = async () => {
+  const { trigger, isMutating: isCreating } = useSWRMutation(
+    "/api/todos",
+    async (_, { arg: name }: { arg: string }) => {
        try {
        await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
        ...
      } catch {
        alert("エラーが発生しました");
      }
    }
+ );
+ const createTodo = () => trigger(name);

  return (
   ...
      {/* タスク入力欄 */}
      <Input
        value={name}
        onChange={(event) => setName(event.currentTarget.value)}
      />
      <Button
        disabled={!name}
+       loading={isCreating}
        onClick={createTodo}
      >
         追加
      </Button>
   ...
  );
};
デモ

フック集ライブラリより非同期処理の状態管理できるフックを利用する

タスク作成処理を非同期処理を管理できるフック経由で実行し、実行中は「追加」ボタンを disabled (もしくは loading)に変更します。
今回はreact-useuseAsyncFnを利用しました。

フック集ライブラリより非同期処理の状態管理できるフックを利用する

TodoApp.tsx
+// react-use パッケージをインストールしておく
+// $ npm install react-use

export const TodoApp = () => {
  ...

  // タスク作成処理
-  const createTodo = async () => {
+  const [{ loading: isCreating }, createTodo] = useAsyncFn(async () => {
        try {
        await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
        ...
      } catch {
        alert("エラーが発生しました");
      }
-   }
+   }, [name]);

  return (
   ...
      {/* タスク入力欄 */}
      <Input
        value={name}
        onChange={(event) => setName(event.currentTarget.value)}
      />
      <Button
        disabled={!name}
+       loading={isCreating}
        onClick={createTodo}
      >
         追加
      </Button>
   ...
  );
};
デモ

対策一覧(React 19 アクション)

React 19 では新しく非同期関数(「アクション」と呼ばれている)をサポートする機能が追加されており、この章にて別枠で紹介させていただきます。アクションについては下記ブログを確認ください。

https://ja.react.dev/blog/2024/04/25/react-19#actions

※ 執筆時点(2024 年 9 月)では React 19 は正式リリースされておらず、デモには rc 版を利用しています。
※ またこの章で紹介する API はすでに Next.js ではサポートされており、React 19 でなくても利用することができます。下記ブログを確認ください。

https://nextjs.org/blog/next-14-2#react-19

useTransition フックを利用して処理中状態を管理する

React 19 のuseTransitionは非同期関数も扱えるようになりました。
使い方は簡単で、useTransitionで囲むだけです。
処理中状態のとき「追加」ボタンを disabled (もしくは loading)に変更します。

※ こちらの方法はコメントにて教えていただきました。(クロパンダさん、Tsuboiさんありがとうございました)

useTransition フックを利用して処理中状態を管理する

TodoApp.tsx
export const TodoApp = () => {
  ...

+ const [isCreating, startTransition] = useTransition();

  // タスク作成処理
- const createTodo = async () => {
+ const createTodo = startTransition(async () => {
    try {
      await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
      ...
    } catch {
      alert("エラーが発生しました");
    }
- }
+ });

  return (
   ...
      {/* タスク入力欄 */}
      <Input
        value={name}
        onChange={(event) => setName(event.currentTarget.value)}
      />
      <Button
        disabled={!name}
+       loading={isCreating}
        onClick={createTodo}
      >
         追加
      </Button>
   ...
  );
};
デモ

useActionState フックを利用して処理中状態を管理する

useActionStateは React 19 で新たに導入されたフックです。
formタグのaction属性(<form> アクション)と組み合わせて利用することで、フォームデータの取得、処理中状態の管理、フォームの自動リセットなどを行うことができます。

useActionState フックを利用して処理中状態を管理する

TodoApp.tsx
export const TodoApp = () => {
  ...

  // タスク作成処理
- const createTodo = async () => {
+ // 今回はuseActionStateの返り値のうち フォームアクション と 処理中状態 を利用します
+ const [, createTodoAction, isCreating] = useActionState(async (_, formData) => {
    try {
      const name = formData.get('name')?.toString();
      if (!name) return;

      await sleep(3000); // 時間のかかるタスク作成 API 呼び出しを想定
      ...
    } catch {
      alert("エラーが発生しました");
    }
- }
+ }, undefined);

  return (
   ...
      {/* タスク入力欄 */}
+     <form action={createTodoAction}>
        <Input
-         value={name}
-         onChange={(event) => setName(event.currentTarget.value)}
+         name="name"
        />
        <Button
-         disabled={!name}
-         onClick={createTodo}
+         type="submit"
+         loading={isCreating}
        >
          追加
        </Button>
+     </form>
   ...
  );
};
デモ

まとめ

いかがだったでしょうか?
今回は React におけるダブルクリック対策をできるだけ多く紹介してみました。

実際の開発ではエラーハンドリングやバリデーション、データの再取得など考慮すべきことはもっと多いはずです。ライブラリを利用したアプローチをいくつか紹介しましたが、ライブラリの機能でそれら考慮事項もだいたい解決できるかと思います。

React はエコシステムが大きく便利なライブラリもたくさんあります。良いコードを書くにはいかにライブラリを知っていて利用できるかが重要かなと思いました。

Discussion

やぎやぎ

様々なケースを記載して下さっており、大変参考になりました。

korosukekorosuke

コメントありがとうございます!お役に立ててよかったです✨

クロパンダクロパンダ

いまだとuseTransitionを使うのも選択肢のひとつだと思います (おそらく今後どんどん主流になるはず)

https://ja.react.dev/reference/react/useTransition#displaying-a-pending-visual-state-during-the-transition

korosukekorosuke

コメントありがとうございます!
useTransitionをどう利用すれば実現できるのかわかりませんでした泣

useTransitionstate 更新に関するフックなので今回のような非同期処理の状態管理には使えなさそう?な気がしております、、、

korosukekorosuke

なるほど、、、補足ありがとうございます! よく理解できました!たしかにv19以降はuseTransitionが主流になりそうですね👀
手元でも確認できたのでにぜひ記事に追記させてください(お二人の名前とともに)

お二人共ありがとうございました!

MeguriMeguri

とても実用的な記事です!ダブルクリックによるAPI呼び出しの問題とその解決策を一覧で整理している点が素晴らしいです。私も最近、APIの管理をしていて、スムーズな操作が重要だと感じています。ECHOAPIというオフラインで使えるAPIテストツールを使ってみたのですが、迅速なテストが可能で、VS Codeとの統合も簡単でした。参考になるかもしれないので、おすすめします!記事を楽しみにしています!