AbortControllerについて with TanstackQuery - mitsuのぶろぐ

mitsuのぶろぐ

基本的にはプログラミングの話のつもり。

AbortControllerについて with TanstackQuery

なんとなく存在はちらっと知っていたのですが、あまりちゃんと触ったことがなかったのでいろいろ試してみました。

AbortController

developer.mozilla.org

一度リクエストが開始されると、基本的にそのリクエストを止めることは難しいです。例えば、UI上でそのリクエストが完了するまでユーザー操作をブロックするような表示をしている場合、ユーザーの行動が制限されてしまいます。また、ブロッキングしない場合でも、重たいファイルのダウンロード中に別の操作をしているときに突然ファイルがダウンロードされるなど、ユーザーにとっては快適な体験とは言えません。

こういった状況で役立つのが、AbortControllerです。AbortControllerを使うことで、リクエストをキャンセルし、ファイルのダウンロードなどを中断することが可能になります。

上記の実装例は上のMDNに譲るとして、以下ではReactの場合や、TanstackQueryを使った場合を見ていきたいと思います。

Reactの場合

例えば画面描画後にリクエストを投げるためにuseEffectを利用する場合、returnとしてunmountされた場合の処理などにAbortControllerでのabortを記載するパターンなどがあるようです。

zenn.dev

確かにunmountしたとしてもリクエストがキャンセルされるわけではないので、このようにキャンセルするのはとても効果的だなと思いました。

TanstackQueryを使う場合

上のReactから発展して、TanstackQueryを利用した場合をみてみたいと思います。 とはいいつつ、詳細な情報は以下に載っています

tanstack.com

const [enabled, setEnabled] = useState(false);
const { data, } =
    useQuery({
      enabled,
      queryKey: ["long"],
      queryFn: async ({ signal }) => {
        const response = await ky.get("/api/long", { signal });
        return response.json<{ text: string }>();
      },
    });

// ...
const onClickFetch = () => {
    setEnabled(true);
  };

const onClickAbort = () => {
    queryClient.cancelQueries({ queryKey: ["long"] });
  };

// ...
// api/long
import type { NextApiRequest, NextApiResponse } from "next";

type Res = {
  text: string;
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Res>
) {
  console.log("long.ts: start");
  const sleep = (ms: number) =>
    new Promise((resolve) => setTimeout(resolve, ms));

  console.log("long.ts: start sleep 3000ms");
  await sleep(3000);
  console.log("long.ts: end sleep 3000ms");

  const now = new Date();
  res.status(200).json({ text: `responsed: ${now.toISOString()}` });
  console.log("long.ts: end");
}

今回の実装では onClickFetch が発火すると useQuery内のenabledがtrueに変わり、refetchされ、api/long が叩かれます。

内部では3秒ほど待つ形になっているので、その3秒の間に onClickAbort が叩かれるとabortされる形になります。

さて、上記ではさらっと「abortされます」と書いていますが、どのように実現しているかというと、 useQueryを使う場合、useQueryの queryFn が AbortController.signal と同等の signal を引数として渡してくれるので、それをapi clientに食わせるだけでよいです。

基本的にuseQueryなどがunmountされたとしてもcacheとして利用できることも踏まえてリクエスト自体はキャンセルされません。 しかし、signalを指定しておくとunmountされたタイミングなどでリクエストもキャンセルされる形となります。

また、今回は挙動を確認したかったため、 onClickAbort が実行されると意図してabortされる形としました。

どのようにabortされたかを判断するか

abortすること自体は上記で問題なかったのですが、「abortされた」かどうかをどのように判断するかが少し疑問でした。

useQueryから返される error , status , fetchStatus , failureReason などの中身を見ていましたがabortされたかどうかの情報は特にありませんでした。

そのため以下のようにabortしたかどうかは判断できるとよいのかなと考えています。

const { data } =
    useQuery({
      enabled,
      queryKey: ["long"],
      queryFn: async ({ signal }) => {
        try {
          const response = await ky.get("/api/long", { signal });
          return response.json<{ text: string }>();
        } catch (error: any) {
          console.log("error.message", error?.message);
          if (error instanceof DOMException) {
            console.log("error.name", error.name);
            if(error.name === "AbortError"){
              // Do something when AbortError
            }
          }
        }
      },
    });

まずはfetchする関数内をtry-catchでくくり、errorを取れるようにしました。

error.messageでもabortしたよ、という情報は確かに入っているのですが、そこから判断するのはあまり現実的ではないと思います。

そこで DOMExceptionAbortError かどうかが判断つくとのことなので error instanceof DOMException で DOMExceptionか判断し、nameがAbortErrorかチェックするとよいなと思いました。

developer.mozilla.org

少し誤解していたこと

abortしたらバックエンド側も何かしら中断されるのかと思っていましたが、そんなことはありませんでした。(それはそう)

上のバックエンドの例の api/long.ts 内の console.logの結果はabortしてもすべて発火し、実行されているようでした。


あんまり「絶対にキャンセルさせたい」というシチュエーションがなかったのでこの手の実装を把握してなかったですが、やはり重たいGETの処理などはたまにあるので、そういった際にはこのあたり実装してみたいと思います。