📘

@tanstack/react-queryの活用まとめ(v4)

2024/01/06に公開

react-queryをどう活用しているか、まとめてみたいと思います。
react-queryの登場によって、redux時代の煩雑さが大幅に解消され、本当に素敵なライブラリと思います。
一方、落とし穴も多く予期せぬエラーにも多く遭遇したため、その辺りも記載しておきます。
※v5やApp Routerでの活用はキャッチアップ中です

グローバルオプションの設定

react-query.ts
const RETRY_COUNT = 3

export const queryConfig: DefaultOptions = {
  queries: {
    // Note Chromeでネットワークが不安定な環境でごく稀にオフライン判定にされ、フェッチが行われないことになるため、networkモードを常にtrueとする
    // https://stackoverflow.com/questions/75538301/reactquery-queryfn-passed-to-usequery-is-never-run-happens-only-on-chrome
    networkMode: 'always',
    suspense: true,
    /**
     * true ErrorBoundaryに遷移させる    :キャッシュがなし、404以外のエラー
     * false ErrorBoundaryに遷移させない :キャッシュがあり、404エラー
     * Note 404は業務的に正常(通常通り画面表示させる)場合でもデータが存在しない場合に返却されるため、ErrorBoundaryに遷移させないハンドリングにする
     */
    useErrorBoundary: (error, query): boolean => {
      if (query.state.data) return false
      if (axios.isAxiosError(error) && error.response?.status === STATUS_CODES.NOT_FOUND) return false
      return true
    },
    // 4xxエラーの場合はリトライしない 5xxの場合は3回リトライする
    retry: (failureCount, error) => {
      if (failureCount === RETRY_COUNT) return false
      if (
        axios.isAxiosError(error) &&
        error.response?.status &&
        error.response?.status < STATUS_CODES.INTERNAL_SERVER_ERROR
      ) {
        return false
      }
      return true
    },
  },
}

デフォルトから変えたオプションの説明

デフォルトのオプションから結構変えています。

  • networkMode
    • こちらコメントに記載していますが、ごく稀にフェッチが一切行われないというやばいバグが発生しました。
    • 深い原因は分かりませんがalwaysにしておくのが無難です。
  • suspense
    • react18以上を使っているなら、trueにしたほうが宣言的なコードが書けます。
    • 後述しますが、色々と不備があります。v5で導入されるuseSuspenseQueryでは解消されているのでしょうか・・
  • useErrorBoundary
    • キャッシュが存在する場合、そのキャッシュを表示させれば良いのでエラー画面へ遷移させないようにしています。
    • ポリシー次第ですが、404は業務的に必ずしもエラーではないということでエラー画面への遷移対象外にしています。
  • retry
    • 400系のエラーはretryして成功することは基本ないと考え、falseに変えています。

エラーハンドリングの設定

QueryClientを作成し、上記で定義したqueryConfigをdefaultOptionsにセットします。

defaultOptions: queryConfig
react-query.ts
export const queryClient = new QueryClient({
  defaultOptions: queryConfig,
  queryCache: new QueryCache({
    onError: (error, query): void => {
      if (!axios.isAxiosError(error)) return
      if (error.response?.status === STATUS_CODES.UNAUTHORIZED) {
        axios
          .get(SESSION_URL.REFRESH)
          .then(() => query.fetch())
          .catch((error) => apiErrorHandling(error))
        return
      }
    },
  }),
  mutationCache: new MutationCache({
    onError: (error, _, __, mutation): void => {
      if (!axios.isAxiosError(error)) return
      if (!mutation.meta?.shouldHandleGlobalError) return
      if (error.response?.status === STATUS_CODES.UNAUTHORIZED) {
        axios
          .get(SESSION_URL.REFRESH)
          .then(() => mutation.execute()) // Note @tanstack/react-queryを4.26.0以上にすると起動しなくなるため、バージョンアップ時に注意すること
          .catch((error) => apiErrorHandling(error))
        return
      }
      apiErrorHandling(error)
    },
  }),
})

フェッチ、mutationでそれぞれエラーハンドリングを入れています。
こちらは、セッションの有効期限が切れて401レスポンスで返ってきた場合に、セッションリフレッシュのエンドポイントを叩き、その後401エラーになったフェッチ(mutation)を再実行するというものです。
後述しますが、フェッチに関しては、リトライ時にsuspenseが剥がれるという事象があり、別途対策する必要があります。

定義したQueryClientをProviderに設定

react-queryを活用するプロバイダーはQueryClientProviderの中に入れる必要があります。

app.tsx
import {QueryClientProvider} from '@tanstack/react-query'

export const AppProvider = ({children}: AppProviderProps): JSX.Element =>
 (<xxxProvider>
    <QueryClientProvider client={queryClient}>
     <xxxProvider>
     {children}
     <xxxProvider/>
    </QueryClientProvider>
 <xxxProvider/>)  

Providerを_app.tsxで読み込む

_app.tsx
<AppProvider>{getLayout(<Component {...pageProps} />)}</AppProvider>

各クエリの発行に関して

オプションを自由に変えたフェッチで乱雑にならないように、基本的にはuseFetch.tsにパターンごとの関数を用意し、それを活用する形でやっています。
以下はデフォルトのオプションを用いるuseFetchとキャッシュタイムを長くもたせるuseFetchMasterを定義しています。

useFetch.ts
const useFetchBase = <T>(
  key: QueryKey,
  queryFn: QueryFunction<AxiosResponse<T>>,
  options?: Omit<UseQueryOptions<AxiosResponse<T>, AxiosError>, 'enabled'>,
): UseFetchReturnType<T> => {
  const result = useQuery<AxiosResponse<T>, AxiosError, AxiosResponse<T>>(key, wrapAuth(queryFn), {
    ...options,
  })
  const {data: resp} = result
  return {...result, data: resp?.data as T}
}

export const useFetch = <T>(key: QueryKey, queryFn: QueryFunction<AxiosResponse<T>>): UseFetchReturnType<T> =>
  useFetchBase(key, queryFn)

export const useFetchMaster = <T>(key: QueryKey, queryFn: QueryFunction<AxiosResponse<T>>): UseFetchReturnType<T> =>
  useFetchBase(key, queryFn, {
    cacheTime: CACHE_TIME,
    staleTime: STALE_TIME,
  })

また、各関数はuseFetchBaseを呼び出すようにしています。

useFetchBaseに関して

まず、dataはas Tにして、Suspenseの思想に準じた形にしています。
※Suspenseはデータ取得が完了するまで、Suspense境界から外れないため、dataはundefinedになることはない
※オプションenableなどを組み合わせると、Suspenseが剥がれることになり、少し危険な設定ではあります。やはり、useSuspenseQueryを活用したいですね。

useFetch.ts
data: resp?.data as T

また、wrapAuthに関しては、前述したフェッチのリトライ時にSuspenseが剥がれることへの対応です。

useFetch.ts
const isPromise = (obj: any): obj is Promise<any> => obj instanceof Promise || (obj && typeof obj.then === 'function')
type QueryFunctionReturnType<T> = Promise<AxiosResponse<T>> | AxiosResponse<T>

/**
 * セッションの有効期限が切れている場合、セッションリフレッシュを実施してからAPIを再実行する
 * @note 本来はtanstack-queryのエラーハンドリングで行うのが望ましいが、
 *       queryCacheで実施するとリフェッチ時にSuspenseが剥がれるため、こちらの仕組みで対応している。
 *
 * @param queryFn  フェッチ定義
 */
const wrapAuth =
  <T>(queryFn: (context: QueryFunctionContext<QueryKey>) => QueryFunctionReturnType<T>) =>
  (args: QueryFunctionContext<QueryKey>): QueryFunctionReturnType<T> => {
    const result = queryFn(args)
    if (isPromise(result)) {
      return result.catch((error) => {
        // エラーの場合、HTTP status codeが401の場合のみこちらに入る
        if (error.response && error.response.status === STATUS_CODES.UNAUTHORIZED) {
          return axios.get(SESSION_URL.REFRESH).then(() => queryFn(args))
        }
        // それ以外のエラーは再度rejectされる
        throw error
      })
    }
    // Promiseではない場合はそのまま返す
    return result
  }

queryCacheのエラーハンドリングに任せない形ですが、きれいな形ではないので、v5になったらqueryCacheのエラーハンドリングに統一できないか確認したいです。

useFetchの呼び出し

  const {data} = useFetch(['cacheKey'], () => XXXXClient.getApiV1XXX())

cacheKeyに関しては、実際は[query-key-factory]というライブラリを活用しています。(https://tanstack.com/query/v4/docs/react/community/lukemorales-query-key-factory)
第二引数に関しては、実態はaxiosのgetです(OpenAPIで自動生成されている)

Mutationに関して

Mutationは少しややこしいので、useCaseをコメントに記載しています。
成功時にinvalidateQueriesを実行することで再フェッチが実行されますので、忘れずに設定する必要があります。

useMutation.ts
export type UseMutateReturnType<TData, TVariables> = UseMutationResult<
  AxiosResponse<TData, any>,
  AxiosError<TData, any>,
  TVariables,
  unknown
>

/**
 * useMutationを使用してデータを取得する
 *
 * @param mutationFunction 更新API定義
 *  - TData     : mutationが返すデータの型
 *  - TVariables: mutate関数の変数の型
 * @param shouldHandleGlobalError グローバルエラーハンドリングを使用するか
 *  リクエストごとに個別でエラーハンドリングしたい場合はfalseにし、mutateにonError関数を渡す
 *  ```typescript
 *  const {mutate: updateXXX, isLoading, isSuccess} = useMutate((param: XxxxParam) => somePromise(param), false)
 *
 * const handleSubmit =
 *   (values: XxxxParam, returnPath: string) => {
 *     updateXXX(values, {
 *       onSuccess: async () => {
 *         await queryClient.invalidateQueries('CacheKey', {exact: true})
 *         await router.push(returnPath)
 *       },
 *      onError: () => {
 *       // 個別のエラーハンドリング(必要なら)
 *      },
 *     })
 *   },
 *  ```
 *  https://github.com/TanStack/query/discussions/3125
 * @return useMutationの戻り値(mutateAsyncはカスタムした形を返却)
 *
 * @package
 */
export const useMutate = <TData, TVariables>(
  mutationFunction: MutationFunction<AxiosResponse<TData>, TVariables>,
  shouldHandleGlobalError = true,
): UseMutateReturnType<TData, TVariables> => {
  const result = useMutation<AxiosResponse<TData>, AxiosError<TData>, TVariables>(mutationFunction, {
    meta: {
      shouldHandleGlobalError,
    },
  })
  return result
}

まとめ

react-queryの導入によって、Redux時代から大幅にコード量が減り恩恵を大きく受けています。
一方で予期せぬエラーや挙動に悩まされることも多く、この記事が少しでもお役に立てれば幸いです。

Discussion