React Query でキャッシュを活用・Authorization ヘッダの入れ方

アクセスのたびにフェッチしない方法、キャッシュの消し方、Authorization ヘッダの入れ方について。

開発環境

  • react: 18.2.0
  • graphql: 16.5.0
  • @tanstack/react-query: 4.0.10
  • @graphql-codegen/cli: 2.11.3
  • @graphql-codegen/near-operation-file-preset: 2.4.0
  • @graphql-codegen/typescript: 2.7.2
  • @graphql-codegen/typescript-operations: 2.5.2
  • @graphql-codegen/typescript-react-query: 4.0.0
  • typescript: 4.7.4

本文

前提:画面から離れる度に再フェッチしない

まず前提として Important Defaults に記載の通り refetchOnWindowFocus というフラグが ON だと「ブラウザで別のタブを開いて元のタブに戻る」という動作をしただけで再フェッチが走る。

これを防ぐためには refetchOnWindowFocus をオフにする。
全クエリでオフにしたい場合は <QueryClientProvider> を書いている部分で defaultOptions として指定する。

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

// Create a client
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
    },
  },
})

function App() {
  return (
    // Provide the client to your App
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  )
}

アクセスのたびにフェッチしない

参考:ReactQueryでキャッシュを最大限利用する

Initial Query Data に書いてある通り、デフォルトでは staleTime が 0 である。

これはつまりキャッシュは常に古いとみなされるので、API を呼ぶ画面にアクセスしたときの挙動が

キャッシュがあればまずキャッシュを返す
→バックグラウンドでフェッチしてキャッシュを更新する
→最初に返したキャッシュとフェッチした内容が違ったら画面を再更新する

というような挙動となる。

常にキャッシュが古いとみなさなくていい場合は staleTime を調整する。
staleTime: Infinity にすると、一度キャッシュしたら明示的に破棄するまでずっと再フェッチしない。

キャッシュを消す

staleTime を 0 以外にした場合、更新したいときはキャッシュを破棄する必要がある。

公式ドキュメント内で “cache” と検索すると QueryCache というのがある。
でもこれはたぶん目的に合致しない。
実際にやってみるとわかるが queryCache.clear() としても再フェッチされず、以前のデータを返してくるのである。

理由と思われることが QueryClient queryClient.resetQueries のほうに書いてあるのだが、clear は subscriber の状態を更新しない。
「古いデータを破棄する」でイメージされる挙動を実現するためには、subscriber の状態のほうを操作する必要があると思われる。

というわけでキャッシュを消したい場合に使うのは queryClient.invalidateQueriesqueryClient.resetQueries になる。

二つの違いは説明を見る限りは

  • invalidateQueries
    • 現在の状態が古いとマークする。(=今持っているデータを即時破棄するわけではないので、一瞬表示される可能性あり)
    • 今アクティブなクエリは再フェッチしてキャッシュを更新し、非アクティブなクエリは後で再フェッチする(ここは設定で挙動を変更できる)。
  • resetQueries
    • クエリをリセットし、クエリを初期状態に戻す。(=今持っているデータを即時破棄する)
    • クエリに InitialData が指定してあるならその状態に戻る。今アクティブなクエリは再フェッチする。

だと思われる。

ユーザーがデータを更新したタイミングでキャッシュを破棄したい場合は invalidateQueries, ユーザーのログイン状態が変わった場合のように前のデータを完全に捨てたい場合は resetQueries になると思う。

どちらも引数の queryKey はオプションで、何も指定しないと全クエリに対して invalidate または reset が実行される。

特定のクエリについて実行したい場合、GraphQL Code Generator で @graphql-codegen/typescript-react-query パッケージを使用しているなら exposeQueryKeys: true というオプションを指定すると QueryKey を取り出すことができるメソッド getKey が追加される。

const key = useUserDetailsQuery.getKey({ id: theUsersId })

これを invalidateQueries, resetQueries の実行時に指定すれば特定のクエリのみ invalidate または reset ができる。

私の現在の実装

ユーザーが更新する可能性があるデータは基本的に staleTime: 0 のままにしている。
複数のブラウザでログインしている場合に「別ブラウザで更新したのに、画面を読み込みなおしても表示が更新されない」という挙動を無くしたいためである。

マスタパラメータのようにユーザーが更新しないものは staleTime: Infinity にしていて、ログイン中は一度も再フェッチしない。

またユーザーを切り替えたとき前のユーザーのデータが表示されるという事故が一番怖いので、ログイン画面を表示したタイミングで

const queryClient = useQueryClient()

useEffect(() => {
  // クエリの全キャッシュを破棄する
  queryClient.resetQueries()
}, [queryClient])

という処理を入れている。
ログイン中に URL 直入力でログイン画面に遷移した場合もキャッシュ破棄されるが、レアケースだし大した害も無いので特に対策はしていない。

Authorization ヘッダの入れ方

GraphQL Code Generator で @graphql-codegen/typescript-react-query パッケージを使用している場合、Authorization ヘッダを入れるには Fetcher を自分で書く必要があると思う。

codegen.yaml を下記のようにする。

overwrite: true
schema: src/graph/schema.graphqls
generates:
  src/graph/types.generated.ts:
    plugins:
      - typescript
  src/graph/:
    documents: src/graph/*.graphql
    preset: near-operation-file
    presetConfig:
      baseTypesPath: types.generated.ts
    plugins:
      - typescript-operations
      - typescript-react-query
    config:
      fetcher:
        func: './Fetchers#requireAuthFetchData'
      exposeQueryKeys: true

fetcher で指定しているのは src/graph/Fetchers.ts というファイルで、中身をこんな感じにするとできる。

const getIdToken = (): Promise<string> => {
  // Authorization ヘッダに入れるトークンを取得する処理
}

export const requireAuthFetchData = <TData, TVariables>(
  query: string,
  variables?: TVariables,
  options?: RequestInit['headers'],
): (() => Promise<TData>) => {
  return async () => {
    const token = await getIdToken()
    const res = await fetch(process.env.NEXT_PUBLIC_API_BASE_URL as string, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
        ...options,
      },
      body: JSON.stringify({
        query,
        variables,
      }),
    })

    const json = await res.json()

    if (json.errors) {
      const { message } = json.errors[0] || {}
      throw new Error(message || 'Error…')
    }

    return json.data
  }
}

フェッチ自体は、一度 React Query と GraphQL Code Generator 初期設定 で記載したように

config:
  fetcher:
    endpoint: "process.env.NEXT_PUBLIC_API_BASE_URL"
    fetchParams: { headers: { "Content-Type": "application/json" } }

みたいな指定で自動生成した中身をコピペし、Authorization ヘッダを入れる部分だけ追加すると良いと思う。

余談:バックエンド側の設定

バックエンド側で Access-Control-Allow-Headers を指定していると思うが、ちゃんと Authorization を入れておかないとエラーになるので注意。

私は Go で下記のようなミドルウェアを使っている。

package cors

import (
	"net/http"
)

func Cors(origin string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", origin)
		w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "content-type, Authorization")
		next.ServeHTTP(w, r)
	})
}

以上