💬

TanStackQueryに学ぶHTTPリクエスト仕様の検討ポイント

2023/09/08に公開

はじめに

私事ですが、弱小バックエンドエンジニアからフロントエンドエンジニアに鞍替えして、早3ヶ月が経ちました。
昨今のアプリは、他システムが提供するAPIをHTTPリクエストで呼び出すことが当たり前ですが、例に漏れず私も業務開発にて当該シーンに直面することに。

鞍替え時の自学で、JavaScriptにおけるHTTPリクエストの方法は習得済み!と意気揚々と取り掛かったのですが・・・

前提知識

  • HTTPリクエストの方法
    • JavaScript 標準の fetchAPI や ライブラリの axios でできるよね
  • JavaScript 非同期制御の機能
    • 通信は非同期で行われるため Promise や async/await を使う必要がある

本記事の目的

前提知識は自学済みだったので正直、HTTPリクエストの実装に課題はないと思っていました。
ということで、早速開発に取り組もうと思ったのですが、なんと開発対象のアプリには TanStack Query なる謎のライブラリが導入されているではないですか。

  • ライブラリ必要?? JavaScript 標準の方法で実装すれば実現できるでしょ!
  • そのための学習が必要で、すぐに開発に取りかかれないよ!

という思いでゲンナリしつつも このライブラリの学習を進めたところ・・・

「HTTPリクエスト」という要件における検討ポイントを、自分は全く把握できていなかった

ということを突きつけられたので、学んだことを忘れないためにいつでも振り返られるよう本記事にまとめておきます。

TanStack Query とは

TS/JS, React, Solid, Vue, Svelte のための 強力な非同期状態管理

きめ細かな状態管理、手作業によるリフェッチ、延々と続く非同期スパゲッティコードを捨て去りましょう。 TanStack Queryは、宣言的で常に最新の自動管理クエリとミューテーションを提供し、開発者とユーザーの両方のエクスペリエンスを直接改善します。

https://tanstack.com/query/latest

非同期状態管理 とは

上記の公式トップページにもある通り、TanStack Query は 非同期状態管理を目的としています。
決して、HTTPリクエストの新しい方法を提供するわけではありません。
そして、この非同期状態管理こそが、HTTPリクエスト要件において検討しなければならないことです。

なるほど、なるほど・・・よくわからん。結局、非同期状態管理ってなんなのさ・・・?

ということで、以降に各論をまとめていきます。

キャッシュ管理

HTTPリクエストとキャッシュ管理は切っても切れない関係にあります。
以下の個別ケースを考えてみると、その理由が理解できると思います。

冗長なHTTPリクエストは行わないようにする

HTTPリクエストはプログラムにおいて、非っ常に重い処理です。そのため、冗長なHTTPリクエストは行わないべきです。
ここで留意すべき点は 「冗長=アプリに関係ない」ではないということです。

AmazonのようなECサイトで以下のような操作を連続して行なった場合を例にしてみます。

  1. アカウント情報ページを表示
    • HTTPリクエストを行い、アカウント情報を取得する
  2. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得する
  3. アカウント情報ページを再度表示
    • HTTPリクエストを行い、アカウント情報を取得する

この例だと、3. にて行うHTTPリクエストは冗長と言えます。なぜなら、1. にて同じ情報を取得済みだからです。そのため、1. にて取得した情報をアプリ内部に保持(キャッシュ)しておき、同じHTTPリクエストの結果が必要となった場合は、内部で保持している情報(キャッシュ情報)を使用するように実装すべきとなります。

  1. アカウント情報ページを表示
    • HTTPリクエストを行い、アカウント情報を取得して 内部に保持(キャッシュ) する
  2. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得して内部に保持(キャッシュ)する
  3. アカウント情報ページを再度表示
    • 1.のキャッシュ情報を使用

古いキャッシュ情報は破棄して再度HTTPリクエストする

内部で保持している情報(キャッシュ情報)を常に使い続けるべきでしょうか?
同じくECサイトで以下のようなシチュエーションを考えてみましょう。

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得して 内部に保持(キャッシュ) する
  2. アカウント情報ページを表示
    • HTTPリクエストを行い、アカウント情報を取得して内部に保持(キャッシュ)する
  3. 商品Aの詳細ページを再度表示
    • 1.のキャッシュ情報(在庫3個)を使用

この例における、3. での動作は問題となる可能性があります。
他のユーザが商品Aを購入していた場合、在庫数が変わっている可能性があるからです。

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得する
  2. アカウント情報ページを表示
    • HTTPリクエストを行い、アカウント情報を取得して内部に保持(キャッシュ)する
  3. (他ユーザ)商品Aを3個購入
  4. 商品Aの詳細ページを再度表示
    • 1.のキャッシュ情報(在庫3個)を使用
    • HTTPリクエストを行い、商品A情報(在庫0個)を取得する

古いキャッシュ情報はHTTPリクエストの隙間に利用する

先述したシチュエーションを考慮して実装すると、商品Aページは常にHTTPリクエストの結果を待たなければなりません。つまり、ユーザからすると「何だか重いアプリだなぁ」と思われてしまいます。

ところで、古いキャッシュ情報は必ず誤っているのでしょうか?
同じく以下のようなシチュエーションを考えてみましょう。

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得する
  2. アカウント情報ページを表示
    • HTTPリクエストを行い、アカウント情報を取得して内部に保持(キャッシュ)する
  3. 誰も商品Aを購入していない
  4. 商品Aの詳細ページを再度表示
    • 再度HTTPリクエストを行い、商品A情報(在庫3個)を取得する

この場合では、古いキャッシュ情報のままでも問題ありませんでした。つまり、正しい情報をすぐに表示することができる状態にも関わらず、ユーザを待たせてしまっています。
このような結果論にもベストを尽くすためには、以下のように実装するべきでしょう。

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得して 内部に保持(キャッシュ) する
  2. アカウント情報ページを表示
    • HTTPリクエストを行い、アカウント情報を取得して内部に保持(キャッシュ)する
  3. (他ユーザ)商品Aを3個購入 or 誰も商品Aを購入していない
  4. 商品Aの詳細ページを再度表示
    • 1.のキャッシュ情報を使用(在庫3個)しつつ、
      HTTPリクエストを行い、商品A情報(在庫3個)を取得して キャッシュ情報を差し替える

キャッシュ情報はアプリ内で共有する

他のページを表示した際にキャッシュした情報は、そのページでのみ使用するべきなのでしょうか?
そんなことはありません。ページが異なっていたとしても、同じHTTPリクエストであれば、キャッシュ情報は共有し、積極的に利用するべきです。

例えば、以下のようなシチュエーションが該当します。

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得して 内部に保持(キャッシュ) する
  2. 商品Aと商品Bのセット商品の詳細ページを表示
    • 1.のキャッシュ情報を使用(在庫3個)しつつ、
      HTTPリクエストを行い、商品A情報(在庫3個)を取得して キャッシュ情報を差し替える
    • HTTPリクエストを行い、商品B情報(在庫7個)を取得して 内部に保持(キャッシュ) する

一見、これは大した問題ではないように思えますが、少なくとも React においては少々厄介です。
状態情報はページレベルで異なるコンポーネントに共有するためには、

  • 上位のコンポーネントで定義するように実装を工夫する
  • Redux, Recoil, Jotai などの状態管理ライブラリを導入する

などをする必要があります。

未使用なキャッシュ情報は削除する

キャッシュした情報をアプリ内にいつまでも保持すると、メモリを圧迫してしまい、アプリの動作不良の原因となってしまいます。
そのため、保持してから一定時間、未使用のキャッシュ情報は削除するべきです。

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得して内部に保持(キャッシュ)する
  2. 他の商品の詳細ページを遷移し続ける
    • 商品Aのキャッシュ情報は未使用のため、削除する
  3. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得して内部に保持(キャッシュ)する

キャッシュ管理の実装

上記で見てきたように、キャッシュ管理は以下の機能を保有する必要があるとわかります。

  • HTTPリクエストの取得結果は、アプリ内共通領域に保存(キャッシュ)する
  • HTTPリクエストを行う場合はキャッシュ情報の有無によって実施を制御する
    • キャッシュが無い場合は、HTTPリクエストを行う
    • キャッシュが新しい場合はそれを使用し、HTTPリクエストを行わない
    • キャッシュが古い場合はそれを使用しつつも、HTTPリクエストを行い、差し替える
  • HTTPリクエスト毎に、古いと見なす時間を指定する
    • アカウント情報は変更頻度が低いため、5分経過後にキャッシュ情報を古いと見なす
    • 商品情報は変更頻度が高いため、常にキャッシュ情報を古いと見なす
  • 未使用の状態が一定時間続いたキャッシュは破棄する

HTTPリクエストに伴って、こんな制御をいちいち実装していたら大変です。。。
実装だけじゃなく、確認するためのテストも大変ですし、バグも沢山でそうです。。。

実装したく無い!! 助けて〜! TanStack Query 〜!!

[React]TanStack Query キャッシュ管理

TanStack Queryはキャッシュ機能を備えており、上記の制御を全て担ってくれます。
使用する側はuseQueryフックを呼び出す際に設定を記載するだけです!

queryKey(必須)

任意で設定する、キャッシュ情報を一意に示すキー。
別コンポーネントからのHTTPリクエストだったとしても、キーが同一ならば同じHTTPリクエストとして、キャッシュ情報を照合し、然るべき制御で返却する。

queryFn(必須)

HTTPリクエストを行う非同期関数。この関数の返却値をキャッシュする。
そのため、TanStack Query は HTTPリクエスト自体には関心を持たず、それ自体はfetchAPIやaxiosを用いて実装する。

staleTime

古いと見なす時間(ミリ秒)。

  • 0 : デフォルト設定。常にキャッシュは古いとみなし、HTTPリクエストを行う
  • Infinity : キャッシュは古くならない。初回のみHTTPリクエストする

cacheTime

キャッシュ未使用と見なす時間(ミリ秒)。未使用状態がこの時間経過した場合、キャッシュはクリアされる。

  • 300000 : デフォルト設定。5分
  • 0 : キャッシュは即座にクリアされる(=キャッシュされない)
  • Infinity : キャッシュがクリアされることがなくなる。

実装例

import { useQuery } from "@tanstack/react-query";
import { fetchData } from "./fetch";

// useQuery定義。HTTPリクエスト単位で定義する
const useQuerySample = () => {
  return useQuery({
    queryKey: ["sample"],
    queryFn: fetchData,
    staleTime: 10 * 1000,     // 保持後、10秒経過で古いと見なす
    cacheTime: 10 * 60 * 1000 // キャッシュの未使用が10分間続いたらクリアする
  });
}

export const Page1 = () => {
  // HTTPリクエストを行う
  // 他ページでキャッシュ済みなら、然るべき制御となる
  const { isLoading, isError, data } = useQuerySample();

  // 通信中は Loading と表示する
  if (isLoading) return <p>Loading...</p>;

  // エラーの場合は その旨を表示する
  if (isError) return <p>ERROR</p>;

  return (
    <div>
      <h3>Page1</h3>
      <pre className="Json">{JSON.stringify(data, null, "  ")}</pre>
    </div>
  );
};

export const Page2 = () => {
  // HTTPリクエストを行う
  // 他ページでキャッシュ済みなら、然るべき制御となる
  const { isLoading, isError, data } = useQuerySample();

  // 通信中は Loading と表示する
  if (isLoading) return <p>Loading...</p>;

  // エラーの場合は その旨を表示する
  if (isError) return <p>ERROR</p>;

  return (
    <div>
      <h3>Page2</h3>
      <pre className="Json">{JSON.stringify(data, null, "  ")}</pre>
    </div>
  );
};

再取得制御

HTTPリクエストでの情報取得はページ表示された時に行います。ですが、そのタイミングだけでしょうか?
以下の個別ケースを考えてみると、情報取得のタイミングに気付くはずです。

ページ表示後、一定時間経過したら情報を最新化する

ページ表示時に、HTTPリクエストを行い情報を取得して表示しました。キャッシュ管理もバッチリです。
めでたしめでたし? いえいえ、以下のようなシチュエーションは、どうでしょう?

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得する
  2. 突然の来客で 30分 ほどの対応が発生
  3. 来客対応後、商品Aの詳細ページの情報を確認

上記のケースでは、2.の来客対応中に商品Aの情報が変わっている可能性があります。そのため、情報を更新するべく、HTTPリクエストを行なった方が良さそうです。
つまり、情報変更の頻度が高いキャッシュ情報は、ページ表示のタイミング以外にも、一定期間毎にHTTPリクエストを行い、情報を最新化する必要があります。

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得する
    • 60秒毎に HTTPリクエストを行い、商品A情報(在庫3個)を取得する
  2. 突然の来客で 30分 ほどの対応が発生
  3. 来客対応後、商品Aの詳細ページの情報を確認

WEBアプリがアクティブじゃないなら再取得を止める

WEBアプリの場合は、一定時間毎の再取得に問題があります。
以下のシチュエーションを考えてみましょう。

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得する
    • 60秒毎に HTTPリクエストを行い、商品A情報(在庫3個)を取得する
  2. ブラウザの別タブで他のページを巡回し始める

WEBアプリがバックグラウンド状態ならば、60秒毎のHTTPリクエストは冗長です。
ユーザがネットサーフィンに夢中で、もはやWEBアプリを開いていることを忘れていることすらあり得ます。
そんな状態でも、せっせとHTTPリクエストをし続けるのは、ユーザの通信量をイタズラに増やすだけです。

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得する
    • 60秒毎に HTTPリクエストを行い、商品A情報(在庫3個)を取得する
  2. ブラウザの別タブで他のページを巡回し始める
    • 60秒毎のHTTPリクエストを止める

WEBアプリがアクティブになったら情報を最新化する

上記の続きとして、ネットサーフィンから戻ってきたらどうでしょうか?

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得する
    • 60秒毎に HTTPリクエストを行い、商品A情報(在庫3個)を取得する
  2. ブラウザの別タブで他のページを巡回し始める
    • 60秒毎のHTTPリクエストを止める
  3. ブラウザの別タブで開いていた商品Aの詳細ページを表示

WEBアプリが再度アクティブになったのなら、その瞬間にHTTPリクエストを行い、情報を最新化すべきです。なぜなら、そのページを表示したことと、同異議の操作だからです。
もちろん、アクティブ状態においては、最新化のための定期的な再取得も再開する必要があります。

  1. 商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得する
    • 60秒毎に HTTPリクエストを行い、商品A情報(在庫3個)を取得する
  2. ブラウザの別タブで他のページを巡回し始める
    • 60秒毎のHTTPリクエストを止める
  3. ブラウザの別タブで開いていた商品Aの詳細ページを表示
    • HTTPリクエストを行い、商品A情報(在庫3個)を取得する
    • 60秒毎に HTTPリクエストを行い、商品A情報(在庫3個)を取得する

再取得制御の実装

まとめるとこうです。

  • 情報変更の頻度が多いHTTPリクエストは、定期的に実行を行う
  • ウィンドウが非アクティブとなったら、定期実行を止める
  • ウィンドウがアクティブに戻ったら、HTTPリクエストを行い、定期実行も再開する

setIntervalを使ったり、focusイベントやblurイベントを監視してどうにか・・・
それを、HTTPリクエスト毎に・・・?

うーん、実装したく無い!! 助けて〜! TanStack Query 〜!!

[React]TanStack Query 再取得制御

TanStack Query は再取得制御をフォローしており、上記の制御を簡単に実現できます。
使用する側はuseQueryフックを呼び出す際に設定を記載するだけです!

refetchInterval

定期的な再取得を行う時間(ミリ秒)。また、非アクティブの場合は再取得を止める。

  • false(or 0) : デフォルト設定。定期的な再取得は行わない。
  • 60000 : 60秒毎に再取得を行う(例)

refetchOnWindowFocus

アクティブとなった場合、再取得を行う設定。

  • true : デフォルト設定。アクティブになった時に再取得する
  • false : アクティブになった時に、再取得を行わない

実装例

import { useQuery } from "@tanstack/react-query";
import { fetchData } from "./fetch";

// useQuery定義。HTTPリクエスト単位で定義する
const useQuerySample2 = () => {
  return useQuery({
    queryKey: ["sample2"],
    queryFn: fetchData,
    refetchInterval: 30 * 1000, // アクティブの場合は30秒毎に再取得を行う
    refetchOnWindowFocus: true  // アクティブになった時に再取得する
  });
}

export const Page3 = () => {
  const { isLoading, isError, data } = useQuerySample2();

  // 通信中は Loading と表示する
  if (isLoading) return <p>Loading...</p>;

  // エラーの場合は その旨を表示する
  if (isError) return <p>ERROR</p>;

  return (
    <div>
      <h3>Page3</h3>
      <pre className="Json">{JSON.stringify(data, null, "  ")}</pre>
    </div>
  );
};

リトライ制御

視点を変えて、HTTPリクエストが失敗した時について考えてみます。
失敗した時は、エラーを表示するのが自然ですね。try - catchの例外ハンドリングで対処しましょう。ですが、直ちにエラー表示として良いのでしょうか?
以下のケースを考えてみると、違う考えが浮かぶはずです。

失敗したら何度かリトライする

失敗しました → 即座にエラー表示。 一見問題なさそうです。

しかし、ここで当たり前を確認してみましょう。HTTPリクエストとは、言わずもがなインターネット通信です。インターネット通信は、常に必ず成功するものでしょうか?
現代に生きる人たちなら、一時的に通信が失敗することは、よくある。という肌感だと思います。
そして、その原因は沢山あります。

  • 地下鉄や山、トンネルなど、通信環境が劣悪なところだから
  • 他の人の通信量が多い時間帯で、遅延しちゃうから
  • 通信端末自体の不具合で、通信がうまく行えないことがあるから
  • etc...

とりあえず謎のエラーが出たら、どうしますか? とりあえず、何度か試してみるのが一般的な行動だと思います。
そして、何回か試したら上手くいった。これも心当たりがあるでしょう。

アプリもこの精神を見習う必要があります。1回失敗したぐらいで諦めず、何度かリトライするように実装すべきです。

リトライ間隔は分散させる

リトライするのはいいですが・・・失敗してから、どれぐらい待てばいいんでしょう? まあ1秒間隔とかですかね。
という安易な考えは更なる問題を呼び込んでしまう可能性があります。

詳細は以下の解説を参照ください。

https://note.com/artrigger_jp/n/n0795148b062d

要するに、単純な一定間隔でのリトライ処理は、通信を受け取る側のサーバーに大きな負荷がかかる可能性があるということです。そして、リトライリクエストの衝突を回避するために、リトライ間隔を指数関数的に増やすべきであるということです。

リトライ制御の実装

つまりこういうことですね。

  • HTTPリクエストが失敗したらインターバルを空けて何度かリトライする
  • インターバルの間隔はサーバ側の負荷を鑑みて Exponential backoff に則る

まあ、try - catchとループ制御を駆使すれば、できるけど・・・めんどくさいな。。
数式に則ったコードは、どっかネットに転がってるでしょ。ググってきてと・・・
あーそうか。HTTPリクエスト全部に入れないとだよね。。HTTPリクエストをラップする共通的な関数を用意するか・・・

なーんてこと考えなくても大丈夫。 そう、 TanStack Query ならね。

[React]TanStack Query リトライ制御

TanStack Query はリトライ制御をフォローしており、上記の制御を簡単に実現できます。
使用する側はuseQueryフックを呼び出す際に設定を記載するだけです!

retry

HTTPリクエストでエラーが発生した際に、リトライを試みる回数。

  • 2 : デフォルト設定。合計3回エラーだったら、諦めてエラーとする。
  • false : リトライしない。直ちにエラーとする。
  • true : 無限に諦めない。

retryDelay(デフォルト推奨)

リトライを試みる際のインターバル(ミリ秒)。
デフォルト設定で Exponential backoff に則っているため、そのままが推奨。

  • 関数 : デフォルト設定。試行回数(attempt)によって指数的に待機時間を増やす
    • attempt => Math.min(attempt > 1 ? 2 ** attempt * 1000 : 1000, 30 * 1000)
  • 1000 : 1秒待機してリトライする(例)

実装例

import { useQuery } from "@tanstack/react-query";
import { fetchData } from "./fetch";

// useQuery定義。HTTPリクエスト単位で定義する
const useQuerySample3 = () => {
  return useQuery({
    queryKey: ["sample3"],
    queryFn: fetchData,
    retry : 4,
    // retryDelay 未指定(デフォルト)
  });
}

export const Page4 = () => {
  const { isLoading, isError, data } = useQuerySample3();

  // 通信中は Loading と表示する
  // リトライ中も通信中と見なされるため Loading と表示され続ける
  if (isLoading) return <p>Loading...</p>;

  // HTTPリクエストが5回失敗した場合、エラーとして その旨を表示する
  if (isError) return <p>ERROR</p>;

  return (
    <div>
      <h3>Page4</h3>
      <pre className="Json">{JSON.stringify(data, null, "  ")}</pre>
    </div>
  );
};

おわりに

これを調べるまで、HTTPリクエストの実装なんて難しいことじゃないと思ってました。
だって、学習のためにサンプルで作ったアプリでは、簡単に動かせましたからね。

ところが、業務で作る実際に使用が想定されるアプリでは、こんなに色々と検討して、実装しないといけないなんて・・・というギャップと共に、これこそが机上では学び切れない、実務経験で培う知識なんだな。と感じます。

適当に作ったお遊びのアプリとは違うんだな。という反省と共に、TanStack Query の偉大さを実感することができました。
今後、同じような場面が訪れたとき、検討すべきポイントを言えるようになりたいですね。

補足:TanStack Query について

実は TanStack Query の紹介、全然足りていません。
そもそも、useQueryしか触れてないし・・・更新系のuseMutationに一切触れてないですね。
情報取得を担うuseQueryについても、もっと色々と機能があります。
React に特化する話になってしまいますが、SuspenseErrorBoundary といったReactの機能をうまく使うための設定とかもあります。
#公式ドキュメントを見ると、すごいパラメータの数だ・・・と面食らうこと間違いなし

ですので、興味を持った方は、ぜひ公式ドキュメントを読んでみてください!

追記内容

2023/09/08
社内の勉強会で使用したところ、 Exponential backoff という考え方をご教示いただきましたので、その内容を反映しました。

Discussion