※Reactのアドベントカレンダーで投稿されていない日があったので2日遅れで代わりに投稿させてもらいます。
多重クリック(連打)をフロントエンド側の対処で防止するためのhookが調べた感じライブラリなさそうだったので作りました。
実態のコードは30行ほどしかないコードなのでスニペットというレベルです。しかしながら、その作成過程で型パズルにハマって苦労したので、自分へのねぎらいも兼ねて書き記します。
なにをしようとしたか
ボタンの多重クリック(連打)は基本的にUIにとって望ましい動作ではないケースが大半かと思います。
ボタンが連携するAPIが取得系などの冪等なAPIの場合は問題になりませんが、リソース作成などのAPIでは多重クリックされ意図しない数のリソースが作成されることになります。
リソースの多重作成にきちんと対処するにはバックエンドも含めた対応が必要ですが、
多重クリック自体に対処する方法としては、Reactでは以下のようなコードを書くノウハウが広く紹介されています。
export const Sample: React.FC = () => {
// ①処理中フラグ
const isProcessing = useRef(false);
const handleClick = async (foo: string) => {
// ②処理中だったらなにもしない
if (isProcessing.current) {
return;
}
try {
isProcessing.current = true; // ③処理前に処理中フラグを立てる
await doSomethingAsync(foo); // API呼び出しなど
} finally {
isProcessing.current = false; //④必ずfalseにもどす
}
};
return (
<div>
<button onClick={() => handleClick("foo")}>Click</button>
</div>
);
};
このノウハウについてはググればいくらでも解説が出てくるので説明は省きますが、ポイントはsetしても即時に値が反映されないstate
ではなく、ref
を利用したフラグワークをする点です。
クラスコンポーネント時代であればクラスのフィールドで表現でよかったのに、ちょっと分かりづらいですね。
これを各イベント処理に記述すれば多重クリックは防ぐことができるわけですが、問題はこれが非常に横展開のしづらい記述であることです。イベント処理の先頭や末尾にちょろっと書けば良い記述であれば、コピペ・置換がしやすいので容易に適用可能ですが、上記の記述の場合、
① ファンクショナルコンポーネントのhook定義
② イベント処理の先頭
③ イベント処理の中のtryブロックの先頭
④ イベント処理の中のfinallyブロックの先頭
と4つ別々の場所に記述する必要があります。これを間違えて「フラグをfalseに戻す」記述をfinallyブロックの外に書いてしまうと、例外発生時にfalseに戻されず、ボタン操作ができなくなるようなケースも考えられます。
共通化したい
こうしたややこしさを避けるためにも、いい感じに共通化したいというのが人情かなと思います。
イメージ的にはこんな感じでラップするだけで防止してくれると便利そう。
const handleClick = useMCP(await doSomethingAsync("foo"))
「よっしゃ、useRef
をラップしたカスタムhook作るで」となり、書いてみるわけですが、
export const useMCP = (f: any): any => {
const isProcessing = useRef(false);
const multipleClickPreventer = async (...args: any) => {
if (isProcessing.current) {
return;
}
try {
isProcessing.current = true;
await f(...args);
} finally {
isProcessing.current = false;
}
};
return multipleClickPreventer;
};
何も考えず諸々をany
にすると当然型の恩恵を捨てることになります。
型解決したい
TypeScriptでは型をどこまで厳密にやるかというの派閥があるかと思いますが、私はわりと型厳密にちゃんと解決したい派です。「少なくともhookのI/F部分についてはany殺したい」マンなので、
「よっしゃ、型解決したるで」と意気揚々と始めたのですが思いのほか型パズルを解くのが大変で四苦八苦しました。
型パズルを解く
Step1
引き受ける関数の条件を表現すると「0個以上の引数を受け取るasyncな関数」ということになります。
これを引数に表現すると以下のようになります。
export const useMCP = (f: (...args: any[]) => Promise<any>) => { ... }
これで、hookに渡される値が以下のようになります。
asyncな関数であることは表現できるようになったものの、まだ関数の引数・戻り値にanyが残ってますね。解消していきましょう。
Step2
hook自体の戻り値は↑のコードの通り、multipleClickPreventer
というラッパー関数です。
したがって、このラッパー関数の引数・戻り値がhookの引数で与えられるf
の引数・戻り値と一致するように解決してやる必要があります。
これを解決するには、TypeScriptが提供してくれているUtility Typesが非常に便利でした。
Utility Typesには関数の 引数を抽出するParameterType と 戻り値を抽出するReturnType が組み込み関数として提供されています。これを適用してやります。
export const useMCP = <F extends (...args: any[]) => Promise<any>>(f: F) => {
const isProcessing = useRef(false);
const multipleClickPreventer = async (
...args: Parameters<F>
): ReturnType<F> => {
...
}
return multipleClickPreventer
}
これで終わりと行きたいところですが、ところがこれだとうまくいきません。戻り値のところで以下のエラーが発生します。
「async関数の戻り値はPromise<T>
型じゃないといけない。Promise<any>
ってこと?」という感じのエラーが出ます。でも、ちゃんとf
の戻り値はPromise<T>
型なんですよねぇ。。。
イベント関数で戻り値を扱うことはまずないように思うので、Promise<any>
にしちゃってもよい気はしたのですが、もう少し頑張って調べてみると、以下のようなIssueが報告されていました。(でも自動クローズされてしまっている... )
ざっくり要約すると、どうもasync関数において、明示的に戻り値のPromise型の<T>
部分を暗黙的に推論することができないということのようです。
執筆時点での最新版であるtypescript@4.9.4
においてもこの問題は発生しており、現状解決する気配はなさそう
Step3
バグじゃんと思うわけですが、現状推論できないのであれば、どうにか回避を考えてみます。
Promise<T>
のT
を暗黙的に推論できないのであれば、明示的に与えられないかを考えます。
すなわち、
-
f
の戻り値のPromise<T>
のT
を明示的に抽出 - 1.で抽出した
T
をPromise
に再度ラップしたものを戻り値の型に設定
を考えることにしました。
「f
の戻り値のPromise<T>
のT
を明示的に抽出」をするにはどうしたらよいか考える上で、ReturnType
の実装が非常に参考になりました。
ご存知の方多いと思いますが、Utility Typesの便利群はすべてTypeScriptで実装されています。ReturnType
だと
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
となっています。infer
などの言語仕様についてはググれば色々出てくるので割愛しますが、これを読み取ると、ReturnType
は「<>
カッコ内で関数(T
)のみを受け取り、その関数T
の戻り値(R
)が推論できるとき、R
を返す」という実装になってます。
これと同様の方法で戻り値のPromise
でラップされた値が取れるのでは?と考えました。
すなわち、「<>
カッコ内で Promise
型のみを受け取り、そのPromise<R>
のR
が推論できるとき、R
を返す」という型抽出です。
型抽出の実装は以下になります。
type AwaitType<T extends Promise<any>> = T extends Promise<infer R> ? R : never;
2022/12/27 追記
Utility Types眺めていたらAwaited Typeあるじゃん
4.5から追加されてるみたいですね。車輪の再発明(しかもクオリテイ低い)でした。。。
そして、これを使ってラッパー関数の戻り値の定義は以下のように修正しました。
export const useMCP = <F extends (...args: any[]) => Promise<any>>(f: F) => {
const isProcessing = useRef(false);
const multipleClickPreventer = async (
...args: Parameters<F>
): Promise<AwaitType<ReturnType<F>> | undefined> => {
...
}
return multipleClickPreventer
}
ReturnType
で取得された戻り値のPromise
を再度AwaitType
で推論して同期処理時の戻り値を抽出し、それをPromise
で再度ラップするということをやっています。
これにより、ちゃんと戻り値が返される場合も解決できるようになりました。(まぁ使わないけど。)
まとめ
ということで、多重クリックを防止するhookを作った過程でハマった型パズルの問題を紹介しました。
TypeScriptの型Generic絡みだすととたんにムズいなぁという印象&謎挙動で割と大変でした。
サラッと書いていますが、それなりにハマってそれなりの時間を溶かしております。
ツッコミあったらお願いします。
では、皆様素敵な年末を。