M

状態管理ツールとしてのReact Query

Oct 15, 2022

これは、Dominik Dorfmeister 氏のブログ記事であるReact Query as a State Managerを日本語訳してみたものです。

誤訳などあればIssueや PR を頂けると幸いです。


React Query は React アプリケーションにおけるデータの取得を劇的に単純化するということで多くの方々から愛されています。そのため、私が React Query は実際のところデータ取得のためのライブラリではありませんと伝えると意外に思われるかもしれません。

データを取得してくれるわけではなく、ネットワークに直結するとても小さな機能のまとまりに過ぎません(OnlineManagerrefetchOnReconnectオフライン下で行われたミューテーションの再試行のような)。 実際に何かデータを取得するにはfetchaxioskygraphql-requestのようなものを使わないといけないことが、初めてqueryFnを記述するときに明らかとなります。

では React Query がデータ取得のライブラリじゃないのであれば、一体何なのでしょうか?

非同期の状態管理ツール

React Query は非同期の状態管理ツールです。あらゆる形の非同期の状態を管理することができます - Promise が得られさえすれば良いのです。そうです、ほとんどの場合、データの取得で Promise を生み出すので、そこで輝きを放ちます。 ただ、読み込み中やエラーを取り扱うだけのものではありません。ちゃんとした本当の”グローバルの状態管理ツール”です。QueryKeyはクエリを一意に識別するので、異なる 2 つのところから同じキーでクエリを実行する限りにおいては、同じデータが得られます。 実際のデータを取得する関数に 2 度アクセスする必要がないようにカスタムフックで抽象化するのが最も良いかもしれません。

export const useTodos = () => useQuery(['todos'], fetchTodos);

function ComponentOne() {
  const { data } = useTodos();
}

function ComponentTwo() {
  // ✅ ComponentOneと全く同じデータを取得する
  const { data } = useTodos();
}

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ComponentOne />
      <ComponentTwo />
    </QueryClientProvider>
  );
}

このようなコンポーネントをコンポーネントツリーのどこにでも置くことができます。 同じQueryClientProviderの配下に置く限り同じデータが得られます。React Query は同時に発生するリクエストの重複を排除するので、上述のような 2 つのコンポーネントで同じデータをリクエストする状況でも 1 つのネットワークリクエストのみとなります。

データの同期ツール

React Query は非同期の状態(データ取得の観点でいうと:サーバの状態)を管理するので、フロントエンドのアプリケーションがデータを”所有”していないと仮定します。 そしてそれは全体的には正しいです。API から取得したデータを画面に表示するとしたら、そのデータの”スナップショット”を表示するだけ - 取得したときにどのように見えるかのバージョンです。 そこで、私たちが問わなければならない問題は次のものです:

取得した後もそのデータは正確なのでしょうか?

その答えは私たちの問題の領域により全く異なってきます。Twitter の投稿をいいねやコメント付きで取得したら非常に速く最新ではない(古い)ものになっている可能性が高いでしょう。日次で更新される為替レートを取得したらしばらくは再度取得せずとも非常に正確なデータでしょう。

React Query はビューと実際のデータの所有者であるバックエンドを同期するための手段を提供します。そうやって、十分に更新しないより頻繁に更新する方が良いとしています。

React Query 以前

React Query のようなライブラリが手助けとなる前、データ取得には 2 つのアプローチが非常に一般的でした。

  • 1 度取得して、全体に届けて、稀に更新
    これは私自身が redux で非常によくやることです。どこかで、通常はアプリケーションのマウント時に行う、データ取得を開始するアクションを発行します。データを得た後、そのデータをアプリケーション全体のどこからでも参照できるようにグローバルの状態管理ツールに置きます。 結局、多くのコンポーネントが Todo リストを参照する必要があります。データを再度取得しますか?いいえ、”ダウンロードして”あり、すでに所有しているものなのでその必要はないでしょう。バックエンドに POST リクエストを送ると”最新の”状態を親切に返してくれるでしょう。 より正確な情報を得たければいつでもブラウザのウィンドウを再読み込みすれば良いでしょう。。。
  • マウントする度に取得してローカルに保持
    場合によっては、データをグローバルな状態にするのは”過剰”と思えることもあるでしょう。モーダルダイアルの中でだけ必要なデータなのであれば、ダイアログを表示するときに限り取得すれば良いのではないでしょうか。 ご存知でしょう:空の依存配列(悲鳴が上がったら eslint-disable を投げつけましょう)を持つuseEffectsetLoading(true) など。。。もちろんデータが得られるまでダイアログを表示する時に毎回ローディングスピナーが表示されるようになります。 他にどうすればいいのでしょう、ローカルの状態はなくなってしまっているのに。。。

どちらのアプローチもとても最適とはいえません。最初の方法はローカルキャッシュを十分な頻度で更新しませんし、2 つ目の方法は潜在的に取得の頻度が多くなって、2 回目の取得時にはデータがないので UX の面でも疑問があります。

それでは、React Query はこのような問題にどのようにアプローチしているのでしょうか。

Stale While Revalidate

前に耳にしたことがあるかもしれませんが、これは React Query が使っているキャッシュメカニズムです。何も新しいことありません - HTTP Cache-Control Extensions for Stale Content についてはこちらをご覧ください。 手短にいうと、React Query はデータをキャッシュして、そのデータがたとえ最新ではない(古い)としても必要な時に返してくれることを意味します。 その原則は、データがないよりも古いデータがある方がましというもので、なぜならデータが無いということははローディングスピナーを意味して、ユーザーが”遅い”と感じるものだからです。同時に、そのデータを再度確認するためバックグラウンドでのデータ取得を試みます。

賢いデータの再取得

キャッシュの無効化は非常に難しいですが、いつ再度新しいデータをバックエンドに要求するのを決めるのでしょうか?もちろん、useQueryを呼ぶコンポーネントの再レンダーの度に毎回そうすることはできないでしょう。現代の基準で見てもとても高くつくことになります。

なので、React Query は賢くデータの再取得のトリガーとなる戦略的なポイントを選びます。ポイントというのは、このように言える指標となりそうなものです:”そうだ、今がデータを取ってくるのに良いタイミングだ”。以下のようなものです:

  • refetchOnMount
    useQueryを呼ぶコンポーネントが新たにマウントされる度に毎回 React Query は再検証します。
  • refetchOnWindowFocus
    ブラウザにフォーカスする度にデータの再取得が行われます。これは私が気に入っている再検証タイミングのポイントですが、よく誤解されます。開発時には、とても頻繁にタブを切り替えるので”過剰”だと感じるかもしれません。 しかし本番環境においては、タブにアプリケーションを開いたままにしておいてメールの確認や Twitter を読むことから戻ってくるようなユーザーが多いようです。 そのような状況では最新の更新を見せることが理にかなっています。
  • refetchOnReconnect
    ネットワーク接続を失った後に戻った時もまた画面に表示するものを再検証するのに良い指標となります。

最終的には、アプリケーションの開発者として良いタイミングのポイントを知っていればqueryClient.invalidateQueriesによって手動の無効化を呼び出すことができます。これはミューテーションを実行した後にとても重宝します。

React Query の魔法をかけよう

前にも言ったように、私はこのような初期設定を好んでいますが、最新の情報を得続けるものであってネットワークリクエストを最少化するためのものではありません。 これはstealTimeの初期値が 0 であることに起因していて、例えば新しいコンポーネントのインスタンスをマウントする度にバックグラウンドで再度取得することを意味します。 これを頻繁に行うと、特に同じレンダーサイクルではない立て続けのマウントがあると、ネットワークタブに多くのデータ取得を目にすることになるかもしれません。何故ならそのような状況で React Query は重複を排除できないからです。

function ComponentOne() {
  const { data } = useTodos();

  if (data) {
    // ⚠️ データを既に持った後に限った条件付きでマウントします
    return <ComponentTwo />;
  }
  return <Loading />;
}

function ComponentTwo() {
  // ⚠️ 従って2回目のネットワークリクエストを引き起こします
  const { data } = useTodos();
}

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ComponentOne />
    </QueryClientProvider>
  );
}

どうなっているんでしょう、2 秒前にデータを取得したばかりなのにどうして他のネットワークリクエストが発生するんでしょうか?正気の沙汰ではないです!

— React Query を初めて使ったときの普通の反応

この時点では、このようなデータの取得が全てが過剰なので、props 経由でデータを渡していくか、props ドリルを避けるのに React Context を使うか、refetchOnMount / refetchOnWindowFocusフラグを無効にするか、いずれも良いアイディアと思えるかもしれません。

一般的には、props でデータを受け渡すのは何も間違っていません。それはできることが最も明らかなもので上述の例でうまく動くでしょう。しかし、より現実的な状況に例を近づけるよう微調整するとどうでしょうか:

function ComponentOne() {
  const { data } = useTodos();
  const [showMore, toggleShowMore] = React.useReducer((value) => !value, false);

  // そうです、”ただの”例なのでエラーハンドリングを省いています
  if (!data) {
    return <Loading />;
  }

  return (
    <div>
      Todo count: {data.length}
      <button onClick={toggleShowMore}>Show More</button>
      // ✅ ボタンがクリックされたらComponentTwoを表示します
      {showMore ? <ComponentTwo /> : null}
    </div>
  );
}

この例では、2 つ目のコンポーネント(これもまた todo のデータに依存している)はユーザーがボタンをクリックした時だけ表示されます。数分後にユーザーがそのボタンをクリックしたと想像してみてください。 その状況では、最新の状態の todo リストを見ることができるようにバックグラウンドでのデータの再取得をするのが良いのではないでしょうか?

もし React Query がやりたいことを基本的に回避するような上述のいずれのアプローチを選択しても実現できません。

ではどうすればケーキを持っていながら食べることもできるのでしょうか?(訳注:両方良いとこどりを意味する表現)

staleTimeをカスタマイズする

おそらくどのような方向に話を進めたいのか既にお分かりかもしれません:解決策としては特定のユースケースに適した値をstealTimeに設定することでしょう:知っておくべきことは:

データが新鮮な限りは、常にキャッシュから取得します。新鮮なデータを何度取ってきてもそのデータのネットワークリクエストが表示されることはありません。

staleTime正しい値もまた存在しません。多くの状況においては初期設定でうまく機能します。個人的には、最低 20 秒に設定してその時間枠の重複を削除するのが好みですが、完全にあなた次第です。

ボーナス:setQueryDefaults を使う

v3 からQueryClient.setQueryDefaults でクエリーのキーごとに初期値を設定する素晴らしい方法をサポートするようになりました。#8: Effective React Query Keysで紹介したパターンを踏襲することで、 設定したいどのような粒度でも初期値を決められて、なぜならsetQueryDefaultsにクエリーのキーを渡すと例えばQuery Filters が持っている標準的な部分一致が適用されるからです:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // ✅ グローバルには20秒が初期設定となります
      staleTime: 1000 * 20,
    },
  },
});

// 🚀 todoに関連する全てが1分のstaleTimeとなります
queryClient.setQueryDefaults(todoKeys.all, { staleTime: 1000 * 60 });

関心の分離についての注意

アプリケーションのあらゆるレイヤーのコンポーネントにuseQueryのような hooks を追加することはコンポーネントが何をするべきかの責務を混合することへの懸念は一見すると正当なことです。 かつては、”smart-vs-dumb”や”container-vs-presenter”というコンポーネントパターンが普遍的でした。これは”props を受け取る”だけのプレゼンテーションコンポーネントであるために、明らかな分離、疎結合、再利用性、テスト容易性が約束されました。 その一方で、大量の props ドリル、ボイラープレート、型付けが難しいパターン(👋 higher-order-components)、恣意的なコンポーネント分割もまた招きました。

それが hooks の登場によって大きく変わりました。useContextuseQueryuseSelector(redux を使っているのであれば)などをどこでも使えるので、コンポーネントに依存を注入できます。 このようにするとコンポーネントをより結合することになると異議を唱えることができます。より自由にアプリケーションの中で移動できて、それだけで動作するので、より独立性が高まったとも言えます。

redux のメンテナーであるMark EriksonによるHooks, HOCS, and Tradeoffs (⚡️️) / React Boston 2019をご覧になることをお勧めします。

要約すると、それは全てトレードオフです。タダで手に入るものはありません。ある状況ではうまく機能するものが別の状況ではうまく機能しないかもしれません。再利用性のあるButtonコンポーネントはデータの取得をするべきでしょうか?おそらくそうではないでしょう。 DashboardDashboardViewDashboardContainerとに分けてデータを受け渡すのが理にかなうでしょうか?これもまたそうではないでしょう。そのためトレードオフを知って適材適所でツールを適用することは私たち次第になります。

まとめ

React Query は、あなたがそうするのであれば、アプリケーションにおけるグローバルな非同期の状態を管理するのに優れています。ユースケースに応じて理にかなった場合に限ってデータ再取得のフラグを無効にして、 サーバのデータを異なる状態管理ツールに同期したくなる衝動を抑えてください。 通常、staleTimeをカスタマイズすることは優れた UX を実現するためにも、バックグラウンドでの更新頻度を管理するためにも必要なものです。