React のダブルクリック(多重クリック)対策一覧
概要
React アプリにおけるダブルクリック(多重クリック)対策の実装とそのデモを一覧でまとめてみました。コーディングのヒントになれば幸いです。
※本記事で紹介する実装は React SPA アプリを想定し、ボタンに対するダブルクリックに焦点を当てています。
ダブルクリックの問題点
ダブルクリックで問題となるのがクリックアクションに紐づく API 呼び出しが複数回行われてしまうことです。
API 側で問題にならないよう設計されてれば安心ですが(トークンを利用するなど)、そうでなかったりそもそも API 側で対策すること自体が難しい場合もあるかもしれません。
今回は TODO アプリのタスク追加機能を想定します。
タスク名を入力し「追加」ボタンをクリックすると、タスクの作成処理(時間のかかる API 呼び出しを想定してください)が実行されタスクが追加されます。
ユーザーが「追加」ボタンをすばやく 2 回クリックすると、タスクの作成処理が 2 回行われてしまいます。
ダブルクリックの問題点
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
)に変更します。
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 を変更することはできません。実行処理がすぐに完了するようなパターンで利用するのがよさそうです。
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>
...
);
};
デモ
disabled
に変更する
ボタンの DOM を直接 タスク作成処理の実行中に「追加」ボタンの DOM を直接 disabled
に変更します。
React の仮想 DOM を通さないので思わぬバグを生むかもしれません。ただ、直接 DOM を変更してるためどのやり方よりも最速でボタンを押せなくさせることができます。こんなやり方もあるよと参考程度に留めるのがよさそうです。
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>
...
);
};
デモ
画面全体にローダーを表示させ操作できなくさせる
タスク作成処理の実行中に画面全体にローダーを表示させユーザーに操作できなくさせます。
ローダー表示中はボタンだけでなくフォームへの入力もできなくなります。問い合わせフォームなどの入力欄の多い画面で利用するとよさそうです。
※ 下記は実装イメージです。実際のコードはデモを確認ください。
+// 画面全体にローダーを出すグローバルなコンポーネント(子コンポーネントならどこでも利用できるようにプロバイダー形式)
+ 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 パッケージのリポジトリより引用
+// 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を利用しました。
+// 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を利用しました。
+// 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-useのuseAsyncFnを利用しました。
+// 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 では新しく非同期関数(「アクション」と呼ばれている)をサポートする機能が追加されており、この章にて別枠で紹介させていただきます。アクションについては下記ブログを確認ください。
※ 執筆時点(2024 年 9 月)では React 19 は正式リリースされておらず、デモには rc 版を利用しています。
※ またこの章で紹介する API はすでに Next.js ではサポートされており、React 19 でなくても利用することができます。下記ブログを確認ください。
useTransition
フックを利用して処理中状態を管理する
React 19 のuseTransition
は非同期関数も扱えるようになりました。
使い方は簡単で、useTransition
で囲むだけです。
処理中状態のとき「追加」ボタンを disabled
(もしくは loading
)に変更します。
※ こちらの方法はコメントにて教えていただきました。(クロパンダさん、Tsuboiさんありがとうございました)
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> アクション)と組み合わせて利用することで、フォームデータの取得、処理中状態の管理、フォームの自動リセットなどを行うことができます。
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
様々なケースを記載して下さっており、大変参考になりました。
コメントありがとうございます!お役に立ててよかったです✨
いまだとuseTransitionを使うのも選択肢のひとつだと思います (おそらく今後どんどん主流になるはず)
コメントありがとうございます!
useTransitionをどう利用すれば実現できるのかわかりませんでした泣
useTransition
はstate
更新に関するフックなので今回のような非同期処理の状態管理には使えなさそう?な気がしております、、、React v19からuseTransitionは非同期にも対応されます!
以下のブログがわかりやすいと思います:
ちなみにNext.js v14だと既に試せます!
なるほど、、、補足ありがとうございます! よく理解できました!たしかにv19以降は
useTransition
が主流になりそうですね👀手元でも確認できたのでにぜひ記事に追記させてください(お二人の名前とともに)
お二人共ありがとうございました!