🫥

@auth0-reactとaxiosとTanstack Queryを使用したAPI通信

2023/12/16に公開

はじめに

はじめまして、株式会社うるるのtakuyaです。

この記事では、Nuxt.js v2からReactへの移行過程で、auth0-reactとaxiosを使用する上で遭遇した困難について、記録として残したいと思います。

今回の想定読者

  • auth0-reactとaxiosの使用に困っている方
  • Tanstack Queryとauth0-reactの組み合わせ方に悩んでいる方

今回の記事の内容

  • auth0-reactの初期設定
  • auth0-reactとaxiosの組み合わせ方
  • Tanstack Queryを用いて、auth0-reactとaxiosの使用時

:::messages
技術スタックとしては、React18、Mantine UI、TailwindCSSを使用していますが、本記事ではこれらについては触れません。また、auth0の基本設定については別の記事をご覧ください。
:::

auth0-reactはhooksのみでの使用がネックですが、今回はそれを解消する方法を採用しました

ざっくりディレクトリ構造

src
├── App.tsx
├── config
│   └── index.ts
├── features
│   ├── holidays
│   │   ├──queries
│   │   │  ├── cache.tsx
│   │   │  └── query.tsx
│   │   ├── useFetchHolidays.tsx
│   │   ├── Holidays.page.tsx
├── hooks
│   ├── useAxios.tsx
│   ├── useCustomMutations.tsx
│   └── useCustomQuery.tsx
├── index.css
├── lib
│   └── axios.ts
├── main.tsx
└── vite-env.d.ts

auth0-reactの初期設定

まず、Providerの設定から始めます。

main.tsx
import { Auth0Provider } from "@auth0/auth0-react";
import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { APP_URL, AUTH0_CLIENT_ID, AUTH0_DOMAIN } from "./config/index.ts";
import "./index.css";

const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false, // エラー時にリトライしない
      refetchOnWindowFocus: false, // フォーカス時にリフェッチしない
      staleTime: FIVE_MINUTES_IN_MS, // 5分間はキャッシュを使う
    },
  },
});

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <Auth0Provider
      domain={AUTH0_DOMAIN}
      clientId={AUTH0_CLIENT_ID}
      authorizationParams={{
        audience: "",
        redirect_uri: `${APP_URL}/occ-next`,
      }}
    >
      <MantineProvider>
        <QueryClientProvider client={queryClient}>
          <App />
        </QueryClientProvider>
      </MantineProvider>
    </Auth0Provider>
  </React.StrictMode>
);

axiosの設定

axiosクライアントを2種類生成しています。一つはTanstack Query用、もう一つはaxiosのみで使用するためです。

import Axios from "axios";
import { APP_URL } from "../config";

export const axios = Axios.create({
  baseURL: `${APP_URL}/api/v2`,
  responseType: "json",
  headers: {
    "Content-Type": "application/json",
  },
});

export const axiosWithToken = (token: string) => {
  return Axios.create({
    baseURL: `${APP_URL}/api/v2`,
    responseType: "json",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
  });
};

auth0-reactのtokenをaxiosに渡すため、hooksを使って以下のように設定しました。

interceptorsを用いてtokenをヘッダーに埋め込む方法です。このソリューションはネット上ではあまり見かけませんが、毎回tokenを埋め込む手間が省けます。
また、リクエスト時とレスポンス時のエラーハンドリングも可能です。

実装の参考になれば幸いです。

import { useAuth0 } from "@auth0/auth0-react";
import { AxiosError, AxiosResponse } from "axios";
import { APP_URL } from "../config";
import { axios } from "../lib/axios";

export const useAxios = () => {
  const { getAccessTokenSilently, logout } = useAuth0();
  axios.interceptors.request.use(async (config) => {
    try {
      const token = await getAccessTokenSilently();
      config.headers.Authorization = `Bearer ${token}`;
    } catch {
      const controller = new AbortController();
      controller.abort();
      logout({ logoutParams: { returnTo: `${APP_URL}/occ-next/login` } });
    }
    return config;
  });

  axios.interceptors.response.use(
    (response: AxiosResponse) => response,
    (error: AxiosError) => {
      if (error.response?.status === 403) {
        const controller = new AbortController();
        controller.abort();
        logout({ logoutParams: { returnTo: `${APP_URL}/occ-next/login` } });
      }
      return error;
    }
  );

  return { axios };
};

Tanstack Queryを用いて、auth0-reactとaxiosの使用時の困難を解決する方法

やはり、axiosを使用してAPIの情報をfetchすると、大体以下のような感じになると思います。

const NoReactQuery = () => {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState()
  const [data, setData] = useState()
  const { axios } = useAxios() // axiosのhookを使うため、tokenの埋め込みを毎回しなくていい

  useEffect(() => {
    setLoading(true)
    const asyncFn = async () => {
      try {
        const res = await axios.get(...なにかのAPIURL)
        setData(res.data)
        setLoading(false)
      } catch (error) {
        setError(error)
        setLoading(false)
      }
    }
    asyncFn()
  }, [])

  if (isLoading) return 'loading...'
  if (error) return 'error...'
}

また、上記のuseAxiosを使用しないと`

const NoReactQuery = () => {
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState()
  const [data, setData] = useState()
  const { getAccessTokenSilently} = useAuth0() // 毎回必要

  useEffect(() => {
    setLoading(true)
    const asyncFn = async () => {
      try {
        const token = await getAccessTokenSilently() // 毎回必要
        const res = await axiosWithToken(token).get(...なにかのAPIURL)
        setData(res.data)
        setLoading(false)
      } catch (error) {
        setError(error)
        setLoading(false)
      }
    }
    asyncFn()
  }, [])

  if (isLoading) return 'loading...'
  if (error) return 'error...'
}

一回のコードがシンプルではないと感じますよね。

しかし、今回Tanstack Queryを使用して、細かく責任を分割して実装してみました。

Tanstack Queryを用いて、auth0-reactとaxiosの使用時

まずは、auth0のtokenを埋め込んでfetchできるような、useQueryとuseMutationのラッパーを作成しました。

useCustomQuery
import { APP_URL } from '@/config'
import { abort } from '@/utils/abort'
import { useAuth0 } from '@auth0/auth0-react'
import { QueryKey, UseQueryOptions, useQuery } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { useCallback, useMemo } from 'react'

export const useCustomQuery = <
  TData = unknown,
  TError = AxiosError,
  TQueryFnData = TData,
  TQueryKey extends QueryKey = QueryKey,
>(
  queryKey: TQueryKey,
  fetcher: (token: string, params: TQueryKey[1]) => Promise<TQueryFnData>,
  options?: Omit<
    UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
    'queryFn' | 'queryKey'
  >,
) => {
  const { getAccessTokenSilently, logout } = useAuth0()

  const queryFn = useCallback(async () => {
    const token = await getAccessTokenSilently()
    return fetcher(token, queryKey[1])
  }, [fetcher, getAccessTokenSilently, queryKey])

  const res = useQuery<TQueryFnData, TError, TData, TQueryKey>({
    queryKey: queryKey,
    queryFn: queryFn,
    ...options,
  })

  if (res.isError && (res.error as AxiosError).response?.status === 403) {
    abort()
    logout({ logoutParams: { returnTo: `${APP_URL}/occ-next/login` } })
  }

  return useMemo(() => ({ ...res }), [res])
}
useMutation.tsx
import { APP_URL } from '@/config'
import { abort } from '@/utils/abort'
import { useAuth0 } from '@auth0/auth0-react'
import { UseMutationOptions, useMutation } from '@tanstack/react-query'
import { AxiosError } from 'axios'
import { useCallback, useMemo } from 'react'

export const useCustomMutation = <TVariables, TData, TContext>(
  fetcher: (params: TVariables, token: string) => Promise<TData | void>,
  options?: UseMutationOptions<TData | void, unknown, TVariables, TContext>,
) => {
  const { getAccessTokenSilently, logout } = useAuth0()

  const mutationFn = useCallback(
    async (params: TVariables): Promise<TData | void> => {
      const token = await getAccessTokenSilently()
      return fetcher(params, token)
    },
    [fetcher, getAccessTokenSilently],
  )

  const res = useMutation({
    mutationFn: mutationFn,
    ...options,
  })

  if (res.isError && (res.error as AxiosError).response?.status === 403) {
    const controller = new AbortController()
    controller.abort()
    logout({ logoutParams: { returnTo: `${APP_URL}/occ-next/login` } })
  }

  return useMemo(() => ({ ...res }), [res])
}

ここらへんは以下の記事を参考にしながら、auth0-reactを組み込んでみました。
上記のところでも、fetchとmutationをするときにtokenを埋め込んで、その他のところでauth0を呼ばなくていいようにしています。

https://zenn.dev/hrbrain/articles/1202f4d107d890#2.-各エンドポイントに対応するusequeryのhooksを作成して共通化する

https://developer.medley.jp/entry/2023/03/31/194059

実際に使用するときは以下のようにして使用しています。
今回はカレンダーの休業日を取得するのを例に見てみます。

Tanstack Queryはfetcherとkeyの管理をしないといけません。そのため、自分はfetcherとkeyを管理は別のファイルに移して管理するようにしました。

featuresディレクトリにqueriesというディレクトリを作成し、その中にquery.tscache.ts を作成。
そのfeaturesでどんなfetcherとkeyをすぐにわかるようにしました。

今回は休業日に関する機能なので、features>holidaysというディレクトリにし、その中のhooksディレクトリ内にqueriesディレクトリがあるという形です。

キャッシュキーの管理のファイル

cache.tsx
import { useQueryClient } from '@tanstack/react-query'
import { useMemo } from 'react'

export const holidaysKeys = {
  all: ['holidays'] as const,
  month: (month: string) => [...holidaysKeys.all, month] as const,
}

export const useHolidaysCache = () => {
  const queryClient = useQueryClient()

  return useMemo(
    () => ({
      invalidateMonth: (month: string) =>
        queryClient.invalidateQueries({ queryKey: holidaysKeys.month(month)       }),
    [queryClient],
  )
}

fetcherの管理ファイル

query.tsx
import { axiosWithToken } from '@/lib/axios'
import { Holidays } from '../../types'

export const query = {
  getHolidays: async (token: string, selectMonth: string) => {
    return await axiosWithToken(token).get<Holidays>('/occ/holidays', {
      params: { month: selectMonth },
    })
  },
}

useCustomQueryに合わせた形のfetcherになっています。

ここから実際にfetchするhooksを作成します

useFetchHolidays.tsx
import { holidaysKeys } from './queries/cache'
import { query } from './queries/query'
import { useCustomQuery } from '@/hooks/useCustomQuery'

export const useFetchHolidays = (selectMonth: string) => {
  return useCustomQuery(holidaysKeys.month(selectMonth), query.getHolidays)
}

keyは一意でないといけないので、cache用の管理ファイルを作成することでミスが起きづらくなると思います。
また、fetcherも別ファイルで管理することにとても見通しがよくなります。

このとき、fetcherの引数にtokenを渡すようにしていることによって、useCustomQueryの部分のtokenがfetcherの引数に渡り、認証トークン付きのリクエストを送信することが可能になりました。

そして、useEffectなどで長く書いていたものが、一行でかけるようになります。

holidays.tsx
export const Holidays = () => {
  const { data, isLoading, isError } = useFetchHolidays()
  if (isLoading) return 'loading ...'
  if (isError) return 'error!!!'
  return (
    <div>{data}</div>
  )
}

上記のようなファイル分割やカスタムフックスを活用することで、auth0-reactのつらい部分をうまく隠蔽できたのではと思います。

最後に

auth0-reactはuseAuth0というフックスを中心に使用していくライブラリなのですが、auth0-spa-jsなどよりも設定や使いやすさ・手軽さがとてもいいなと思っています。

tokenを埋め込む処理が大変だったりしますが、概ね認証が簡単に実装できるので満足しています。

今回の実装例で不明な点が多々あると思いますが、備忘録てきなものなので許して下さい。
また、さらにこの部分はどうしたの?などがあればコメントいただけると幸いです。

だれかのヒントになれば嬉しいです!

Discussion