🌊

TanStack Queryを用いたキャッシュハンドリング入門

2023/04/03に公開

こんにちは、エビリーでmillvi (ミルビィ)のフロントエンド開発を担当しているイケダと申します。

millviのフロントエンドではAPIのData Fetchingライブラリとして、TanStack Query(旧React Query)を採用しています

TanStack Queryを利用すると、取得したデータをキャッシュとして保持することができ、APIのリクエストを減らすことができます。

ですが、これを実現するには適切な設定を行う必要と感じたため、関連する機能について最低限まとめました。

はじめに

以下ではnext.jsを利用した場合で、話を進めます。
next.jsとTanStack Queryを利用したプロジェクトを作成するには、以下のコマンドを実行します。

# Next.js のプロジェクトを作成
$ npx create-next-app tanstack-train --ts

# TanStack Queryをインストール
$ npm i @tanstack/react-query

次に以下のようにpages/_app.tsxにQueryClientProviderを設定します。

pages/_app.tsx

import type { AppProps } from "next/app";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

export default function App({ Component, pageProps }: AppProps) {
  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  );
}

これで、TanStack Queryを利用する準備が整いました。

TanStack Queryを利用したキャッシュの設定方法

TanStack Queryを用いて、APIからデータを取得するにはuseQuery Hookにデータを取得する関数を渡します。取得したデータは、キャッシュとして保持されます。

useQuery Hook の使い方

pages/index.tsxに以下のようにuseQuery Hookを設定します。
この例では、あらかじめ用意したAPIからTodoアプリのデータを取得しています。

pages/index.tsx

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

type Todo = {
  id: number;
  task: string;
  isCompleted: boolean;
};

// APIからTodoデータを取得する関数
const getTodoItems = async (): Promise<Todo[]> => {
  const res = await fetch("api/todo");
  return res.json();
};

export default function Home() {
  // useQuery Hookを利用して、Todoデータと関連する情報を取得する
  const {
      data,
      isLoading,
      // isFetching,
      error
  } = useQuery({
    queryKey: ["todo"],
    queryFn: getTodoItems,
    refetchOnWindowFocus: false,
    staleTime: 1000 * 60 * 5,
    cacheTime: Infinity,
  });

  // useQuery Hookの戻り値であるisLoadingを利用することで、データの取得が完了するまでローディングを表示する
  if (isLoading) return 'Loading...'

  // useQuery Hookの戻り値であるerrorを利用することで、エラーが発生した場合にエラーを表示する
  if (error) return 'An error has occurred'

  return (
    <div>
      <h1>TODO ITEMS</h1>
      <ul>
        {data?.map((item) => (
          <li key={item.id}>{item.task}</li>
        ))}
      </ul>
    </div>
  );
}

useQuery Hookに渡すオブジェクトのパラメータについて説明します。

  const {
      data,
      isLoading,
      // isFetching,
      error
  } = useQuery({
    queryKey: ["todo"],
    queryFn: getTodoItems,
    refetchOnWindowFocus: false,
    staleTime: 1000 * 60 * 5,
    cacheTime: Infinity,
  });

queryKey

queryKeyには、キャッシュを一意に参照するための配列を指定します。これは、キャッシュの取り扱う際に利用されます。

これを活用するイメージとしては、クエリパラメータを指定するGETリクエストを行う際に、クエリパラメータをqueryKeyに組み込むことで、クエリパラメータが異なるリクエストごとにキャッシュを分けることができます。

完了したタスクのみ取得するクエリをキャッシュする場合のイメージ

queryKey: ["todo", { isCompleted: true }]

未完了のタスクのみ取得するクエリをキャッシュする場合のイメージ

queryKey: ["todo", { isCompleted: false }]

queryFn

queryFnには、データを取得する関数を指定します。
指定する関数はpromiseを返す必要があることに注意してください。

const getTodoItems = async (): Promise<Todo[]> => {
  const res = await fetch("api/todo");
  return res.json();
};

今回はfetch APIを使って、Todoアプリのデータを取得する関数を作成しましたが、axiosなどのライブラリを使っても問題ありません。

refetchOnWindowFocus

refetchOnWindowFocusは、ブラウザのタブがフォーカスされた際に、データを再取得するかどうかを指定します。

staleTime

staleTime には、キャッシュの有効期限をmsで指定します。
この値を超える時間が経過すると、キャッシュが古くなったものとして扱われ、次回データを再取得するようになります。

ここで重要なのは、「キャッシュが古くなった物として扱う」という表現で、実際にはデータを再取得するまでは古いキャッシュが利用されます。
そして、データを再取得すると、新しいキャッシュに置き換えられ、画面に反映されます。

サンプルの例だと再取得中はisLoadingfalseのままで、となり「Loading...」画面が表示されることはありません。

staleTimeデフォルト値は0となっているため、デフォルトのまま利用する際は注意が必要です。

余談ですが、実際に動かしてみたところrefetchOnWindowFocusがtrueの場合でも、staleTime 内であればデータは再取得されないようです。
staleTimeを適切に設定していれば、refetchOnWindowFocusはそれほど気にする必要はないかもしれません。

cacheTime

cacheTimeには、キャッシュの保持期限をmsで指定します。
この値を超える時間が経過すると、キャッシュは削除されます。

staleTimeとの違いは、「キャッシュが削除される」という点で、キャッシュ未所持の状態となるため、サンプルの例だと、データが再取得されるまでisLoadingtrueとなりLoading画面が表示されます。

例では、Infinityを指定しているため、キャッシュは削除されません。
デフォルト値は5分となっているため、5分以上経過するとキャッシュは削除されます。

基本的には、staleTimeよりもcacheTimeを長く設定しておけばそれほど問題はないと思います。

キャッシュの更新方法について

基本的に、useQuery Hookで取得したデータのキャッシュは、staleTime経過するまで更新されません。
しかし、実際にはデータの追加・更新・削除などの操作を行った際に、キャッシュを更新する必要があります。

ここではuseQuery Hookで取得したキャッシュを任意のタイミングで更新・削除する方法を説明します。
キャッシュの更新・削除には、useQuery Hookから呼び出せるqueryClientを利用します。

queryClient

  const queryClient = useQueryClient();

queryClientにはいくつかの便利なメソッドが用意されています。
それぞれのメソッドは、キャッシュやAPIとのやりとりを操作するための機能を提供しています。

ここでは、invalidateQueriesrefetchQueriesremoveQueriesresetQueriesについて、それぞれの違いを説明します。

invalidateQueries

    queryClient.invalidateQueries({ queryKey: ["todo"] });

invalidateQueriesメソッドは、指定されたqueryKeyに関連するすべてのキャッシュデータを無効にするために使用されます。
無効化されたキャッシュを扱うクエリが現在レンダリング中のコンポーネント内で使用されている場合、invalidateQueriesメソッドが呼び出された時点でバックグラウンドで再取得されます。

挙動としては、キャッシュが更新されるまでは、古いキャッシュが利用されるので、Loading...の画面が表示されることはありません。

クエリが現在レンダリング中のコンポーネント内で使用されていない場合は、次に使用されるタイミングで同様に再取得されます。

またqueryKeyの指定は前方一致で指定することができ、invalidateQueries({ queryKey: ["todo"] })とすることで、先に例で示したqueryKey: ["todo", { isCompleted: true }]queryKey: ["todo", { isCompleted: false }]に関連するキャッシュデータを無効にすることができます。

キャッシュの無効化をqueryKey: ["todo"]に限定したい場合は、invalidateQueries({ queryKey: ["todo"], exact: true })とすることで、完全一致で指定することもできます。

refetchQueries

    queryClient.refetchQueries({ queryKey: ["todo"] });

refetchQueriesメソッドは、指定されたqueryKeyに関連するすべてのキャッシュデータを再取得するために使用されます。
invalidateQueriesメソッドとの違いは、refetchQueriesメソッドは、指定したqueryKeyに関連するクエリが現在レンダリング中のコンポーネント内で使用されているかどうかに関わらず、refetchQueriesメソッドが呼びだされたタイミング再取得を行う点です。

つまり、現在表示しているページに関連しないクエリのキャッシュも更新できるということです。
これは、別のページに遷移した時、すでにキャッシュが更新されているので、古いデータが表示されることを防ぐことができる一方で、場合によっては不要なクエリを大量に再取得することになるため、注意が必要です。

resetQueries

    queryClient.resetQueries({ queryKey: ["todo"] });

resetQueriesメソッドは、指定されたqueryKeyに関連するすべてのクエリのキャッシュデータをリセットするために使用されます。
挙動としては、一度キャッシュを削除した後、再取得を行うので、再取得が完了するまでは、useQuery HookのisLoadedが再びtrueの状態になります。

resetQueriesメソッドは、invalidateQueriesメソッドと同様に、現在レンダリング中のコンポーネント内で使用されているクエリに関しては、resetQueriesメソッドが呼び出された時点で再取得され、そうでないクエリに関しても次に使用されるタイミングで再取得されます。

removeQueries

    queryClient.removeQueries({ queryKey: ["todo"] });

removeQueriesメソッドは、指定されたqueryKeyに関連するすべてのクエリのキャッシュデータを削除するために使用されます。
resetQueriesメソッドとの違いは、removeQueriesメソッドは、キャッシュの削除のみを行い、再取得は行わない点です。

TanStack Queryを用いた作成・更新・削除について

TanStack Queryにはデータの作成・更新・削除を行うためのuseMutation Hookが用意されています。

キャッシュの更新と組み合わせて利用することも多いと思うので、ここで簡単に紹介しておきます。

以下はTODOの追加を行う例です。

まず、TODOを追加するAPIのリクエストを行う関数を作成します。

const addTodo = async ({
  task,
}:
{
  task: string;
}) => {
  const res = await fetch("api/todo", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      task,
    }),
  });
  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText}`);
  }
  return res.json();
};

これに対して、useMutation Hookを利用して、TODOを追加する関数を作成します。

  const queryClient = useQueryClient()

  const addMutation = useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
        queryClient.invalidateQueries({ queryKey: ["todo"] });
  },
    onError: (error) => {
      console.log(error);
    },
});

const handleAdd = async () => {
  await addMutation.mutate({ task: task });
};

上記のように、成功時にqueryClientのinvalidateQueriesメソッドを呼び出すことで、TODOの一覧を取得するクエリのキャッシュを更新することができます。

まとめ

今回は、TanStack Queryのデータ取得とキャッシュの取り扱いについて解説しました。
キャッシュをうまく扱えると、アプリケーションのパフォーマンスが向上するだけでなく、ユーザーのストレスを軽減できるので、ぜひ考慮してみてください。

今回の記事作成にあたって、作成したコードははこちらです。
ただし、あくまでも動作確認用のコードなので、その点ご了承ください。
https://github.com/eviry-yikeda/tanstack-query-simple-example

また、今回は扱わなかった機能についても、公式ドキュメントに詳しく記載されているので、興味がある方はぜひご覧ください。

最後に

弊社でのプロダクト開発や技術に少しでも興味を持っていただけた方は、以下のリンクからお気軽にご連絡ください。
https://recruit.eviry.com/

Discussion