21
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ReactAdvent Calendar 2022

Day 24

多重クリックを防止して型解決できるカスタムhook作るために型パズル解いた。

Last updated at Posted at 2022-12-26

※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にすると当然型の恩恵を捨てることになります。
image.png

型解決したい

TypeScriptでは型をどこまで厳密にやるかというの派閥があるかと思いますが、私はわりと型厳密にちゃんと解決したい派です。「少なくともhookのI/F部分についてはany殺したい」マンなので、
「よっしゃ、型解決したるで」と意気揚々と始めたのですが思いのほか型パズルを解くのが大変で四苦八苦しました。

型パズルを解く

Step1

引き受ける関数の条件を表現すると「0個以上の引数を受け取るasyncな関数」ということになります。
これを引数に表現すると以下のようになります。

export const useMCP = (f: (...args: any[]) => Promise<any>) => { ... }

これで、hookに渡される値が以下のようになります。

スクリーンショット 2022-12-25 21.38.16.png

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
}

これで終わりと行きたいところですが、ところがこれだとうまくいきません。戻り値のところで以下のエラーが発生します。
image.png
「async関数の戻り値はPromise<T>型じゃないといけない。Promise<any>ってこと?」という感じのエラーが出ます。でも、ちゃんとfの戻り値はPromise<T>型なんですよねぇ。。。

イベント関数で戻り値を扱うことはまずないように思うので、Promise<any>にしちゃってもよい気はしたのですが、もう少し頑張って調べてみると、以下のようなIssueが報告されていました。(でも自動クローズされてしまっている... :cry: )

ざっくり要約すると、どうもasync関数において、明示的に戻り値のPromise型の<T>部分を暗黙的に推論することができないということのようです。
執筆時点での最新版であるtypescript@4.9.4においてもこの問題は発生しており、現状解決する気配はなさそう :rolling_eyes:

Step3

バグじゃんと思うわけですが、現状推論できないのであれば、どうにか回避を考えてみます。
Promise<T>Tを暗黙的に推論できないのであれば、明示的に与えられないかを考えます。
すなわち、

  1. fの戻り値のPromise<T>Tを明示的に抽出
  2. 1.で抽出したTPromiseに再度ラップしたものを戻り値の型に設定

を考えることにしました。

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あるじゃん :scream:
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で再度ラップするということをやっています。
これにより、ちゃんと戻り値が返される場合も解決できるようになりました。(まぁ使わないけど。)

image.png

まとめ

ということで、多重クリックを防止するhookを作った過程でハマった型パズルの問題を紹介しました。
TypeScriptの型Generic絡みだすととたんにムズいなぁという印象&謎挙動で割と大変でした。
サラッと書いていますが、それなりにハマってそれなりの時間を溶かしております。
ツッコミあったらお願いします。
では、皆様素敵な年末を。

21
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?