🎃

AspidaとTanStack Query(React Query)を使ったお手軽キャッシュ最適化

2023/03/16に公開

今回の主役

使用するライブラリ


<!-- openapiの定義ファイルがないとき -->
pnpm i axios @aspida/axios @tanstack/react-query 

<!-- openapiの定義ファイルがあるとき -->
<!-- 本記事ではopenapi2aspidaの使い方については説明しませんが、とても便利です! -->
pnpm i axios @aspida/axios @tanstack/react-query openapi2aspida 

TL;DR

  • Aspidaで生成されたコードを使ってReact QueryのQueryKeyを自動で設定するカスタムフックを作ろう
  • デフォルトのstaleTimeを長く設定してキャッシュの恩恵を最大限受けよう
  • React QueryのQuery invalidation機能を使ってキャッシュの再取得をするカスタムフックを作ろう

React Queryとは?

TanStack Query (FKA React Query) is often described as the missing data-fetching library for web applications, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your web applications a breeze.

ざっくり言うとデータフェッチやキャッシュ周りをいい感じに管理してくれる+React Hooksを使って簡潔に記載できるライブラリです。

公式ドキュメントのOverviewから引用してコメントで解説を追記します。
もう概要は知っているよという方は読み飛ばしてください。

公式ドキュメントに解説を追記したコードサンプル
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query'

// queryClientの内部でcache等の状態を持つ
const queryClient = new QueryClient()

export default function App() {
  // QueryClientProviderでラップした内部のコンポーネントから状態へアクセス可能
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

function Example() {
  // isLoading, error, dataなど共通のインターフェースでレンダリングに必要な情報を取得できて便利
  const { isLoading, error, data } = useQuery({
    // 今回の記事のキモ!次項で解説しています
    queryKey: ['repoData'],
    queryFn: () =>
      fetch('https://api.github.com/repos/tannerlinsley/react-query').then(
        (res) => res.json(),
      ),
  })

  if (isLoading) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{' '}
      <strong>{data.stargazers_count}</strong>{' '}
      <strong>🍴 {data.forks_count}</strong>
    </div>
  )
}

Query Keysによるキャッシュ制御

useQueryのキャッシュの管理はqueryKey引数に基づいて制御されます。
同じqueryKeyを渡して呼び出した際にfreshなキャッシュがあるとそれを返し、キャッシュが存在しなかったりキャッシュがstale(freshでない)な場合はqueryFnをコールして新たにデータをキャッシュしてくれます。

Query Keysは、以下のような特徴を持っています。 (公式ドキュメントのQuery Keysから抜粋)

  1. 配列である
  2. 文字列やオブジェクトなどを含むことができる
  3. オブジェクト内の並び順はqueryKeyの同値判定に関係しない
  4. 配列の要素の並び順はqueryKeyの同値判定に関係する

コードで解説すると、以下の通りです。

// 全て同じ`queryKey`として処理される
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })

// 全て異なる`queryKey`として処理される
useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ...})
useQuery({ queryKey: ['todos', undefined, page, status], ...})

Query Invalidation機能

Query Invalidation

useQueryを呼ぶ際に、オプションにstaleTimeを渡してキャッシュの時間を制御することができます。
ただし、実際のアプリケーションでは特定のタイミング(ユーザーアクションの後など)にデータを際再取得する場面が多々あります。

このとき、useQueryの返り値にあるrefetchをコールしてデータの再取得をすることができます。

// クリック後にデータを再取得する例
const ComponentA = () => {
  const { data, refetch } = useQuery({ 
    queryKey: ['hoge', 'fuga'],
    staleTime: 5 * 1000 * 60, // キャッシュは5分有効
    ...
  })

  // clickされた際、処理を行なってデータの再取得を行う
  const handleClick = () => {
    ...
    refetch()
  }

  return (
    <div>
      <p>{data.piyo}</p>
      <button onClick={handleClick}>click me!</button>
    </div>
  )
}

しかし、上記例のComponentAとは別のコンポーネントで呼ばれているuseQueryのデータも再取得するべき対象であった場合はどうしましょう?

例に挙げると以下の通りです。

  • ComponentAでclickされたらComponentBのデータも再取得しないと不整合が起きる
  • ComponentBがマウントされたとき、キャッシュはまだfresh
const ComponentA = () => {
  const { data, refetch } = useQuery({ 
    queryKey: ['hoge', 'fuga'],
    staleTime: 5 * 1000 * 60, // キャッシュは5分有効
    ...
  })

  const handleClick = () => {
    ...
    refetch()
  }

  return (
    <div>
      <p>{data.piyo}</p>
      <button onClick={handleClick}>click me!</button>
    </div>
  )
}

const ComponentB = () => {
  const { data, refetch } = useQuery({ 
    queryKey: ['hoge2', 'fuga2'],
    staleTime: 5 * 1000 * 60, // キャッシュは5分有効
    ...
  })

  return (
    <div>
      <p>{data.piyo2}</p>
    </div>
  )
}

パッと出てくる解決策としてはこのような感じかなと思います。

  • refetchをバケツリレーする
  • refetchOnMount: 'always'をオプションに追加して常にマウント時にはデータを再取得する

しかし、コードが汚れてしまったりせっかくのキャッシュ機能をフルに活用できなくなってしまいます。

そこで出てくるのがQuery Invalidation機能です。

以下のコードは公式ドキュメントの抜粋ですが、queryClient.invalidateQueriesの引数にinvalidateしたい(キャッシュを強制的にstaleとしたい)queryKeyを渡すことで特定のタイミングでデータを勝手に再取得してfreshな状態にしてくれます。

特定のタイミングとは

以下のオプションでtrueを指定している場合、キャッシュがstaleならば再取得が走ります。

  • refetchOnMount
  • refetchOnReconnect
  • refetchOnWindowFocus
import { useQuery, useQueryClient } from '@tanstack/react-query'

// Get QueryClient from the context
const queryClient = useQueryClient()

queryClient.invalidateQueries({ queryKey: ['todos'] })

// Both queries below will be invalidated
const todoListQuery = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
})
const todoListQuery = useQuery({
  queryKey: ['todos', { page: 1 }],
  queryFn: fetchTodoList,
})

Query Keysの理解しておくべき重要ポイント

公式ドキュメントのQuery Keys項ではqueryKeyの同値判定の仕組みのみ解説されていますが、Query Invalidationでサラっと説明されている重要な仕組みがあります。

以下のサンプルコードにある通り、queryKeytodosから始まっているもの全てを対象にinvalidateが実行されるという点です。

// Invalidate every query with a key that starts with `todos`
queryClient.invalidateQueries({ queryKey: ['todos'] })

つまり、queryKeyの1つ目の要素に渡す値はReact Queryの機能を活用する上で他の要素より重要な役割を持つということです。

Aspidaとの連携

ここからやっとこの記事の本題に入ります。
Aspidaと一緒にQuery Invalidation機能をいい感じに使いこなすために、

  1. Aspidaクライアントの各エンドポイントの$pathの返り値をqueryKeyの1つ目の要素としてセットできるカスタムフックの作成
  2. Query Invalidationを楽に行なえるカスタムフックの作成
// '$path'はこのような値が返ります

aspidaClient.users.$path()/users
aspidaClient.users._userId('hoge').$path()/users/hoge

1. useAspidaQuery

こちらのリポジトリをめちゃくちゃ参考にしています。
型むっず。

aspida-react-query

  • aspida-react-queryが古かったので最新のReact Queryで動くように型を修正
  • queryKeyの1つ目の要素を$path()の返り値にする
    • aspida-react-queryでは$path(opt)だったため、Query parametersがある場合にうまく動かなかった

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

type QueryOptions<T extends (option: any) => Promise<any>> = Parameters<
  Parameters<T> extends [Parameters<T>[0]]
  ? (
    option: Parameters<T>[0] &
      UseQueryOptions<ReturnType<T> extends Promise<infer S> ? S : never>
  ) => void
  : (
    option?: Parameters<T>[0] &
      UseQueryOptions<ReturnType<T> extends Promise<infer S> ? S : never>
  ) => void
>;

type QueryResult<T extends (option: any) => Promise<any>> = UseQueryResult<
  ReturnType<T> extends Promise<infer S> ? S : never,
  any
>;

function useAspidaQuery<
  T extends Record<string, any> & {
    $get: (option: any) => Promise<any>;
    $path: (option?: any) => string;
  }
>(api: T, ...option: QueryOptions<T["$get"]>): QueryResult<T["$get"]>;

function useAspidaQuery<
  T extends Record<string, any> & { $path: (option?: any) => string },
  U extends {
    [K in keyof T]: T[K] extends (option: any) => Promise<any> ? K : never;
  }[keyof T]
>(api: T, key: U, ...option: QueryOptions<T[U]>): QueryResult<T[U]>;

function useAspidaQuery<
  T extends Record<string, any> & { $path: (option?: any) => string },
  U extends {
    [K in keyof T]: T[K] extends (option: any) => Promise<any> ? K : never;
  }[keyof T]
>(api: T, key: U, ...option: Parameters<T[U]>) {
  const method = typeof key === "string" ? key : "$get";
  const opt = typeof key === "string" ? (option as any)[0] : key;

  // $pathが同じ場合はキャッシュを共有する
  const baseQueryKey =
    api.$path() === api.$path(opt)
      ? [api.$path()]
      : [api.$path(), api.$path(opt)];

  return useQuery(
    typeof key === "string" ? [...baseQueryKey, method] : [...baseQueryKey],
    () => api[method](opt),
    opt
  );
}

export { useAspidaQuery };

2. useInvalidateQueries

引数に渡した$path()の値をまとめてinvalidateするカスタムフックです。

export const useInvalidateQueries = (...paths: string[]) => {
  const queryClient = useQueryClient();

  return () => {
    paths.forEach((path) => {
      queryClient.invalidateQueries({ queryKey: [path] });
    });
  };
};

一気に共通のドメイン群のinvalidateをするためにテンプレートを用意してあげると便利です。

export const invalidateQueriesTemplate = {
  hoge: (hogeId: string) => [
    aspidaClient.hoge.$path(),
    aspidaClient.fuga.hoge.$path(),
    aspidaClient.hoge._hoge(hogeId).$path(),
  ],
  ...
};

// このように使える
useInvalidateQueries(...invalidateQueriesTemplate.hoge(hogeId))

まとめ

React Queryは非常に多機能であるため、すべてを把握することは難しいかもしれません。しかし、React Queryを使用することで、キャッシュ制御やデータの取得・更新に関する多くの機能を簡単に実装することができます。

この記事では、React Queryを使用したカスタムフックを実装し、キャッシュの制御方法について紹介しました。ただし、アプリケーションに合わせたパラメータを完全に制御する場合は、queryClientのキャッシュを直接操作することが必要になる場合もあります。

React Queryを使用することで、アプリケーションのデータフローを効率的かつ柔軟に制御することができます。より高度な使い方については、公式ドキュメントやコミュニティの情報を参考にしてください。また、もしもっといい方法や便利なカスタムフックがある場合は、コメントで教えていただけると幸いです。

(まとめを適当に書いたらめっちゃかっこよくChatGPTさんが書きなおしてくれたすげー、、、)

Discussion