【React】React Queryとカスタムフックを用いたログインの実装

#はじめに
ログイン画面の実装にReact Queryを使ってみたので記事にまとめました。

React Query関連の別の記事も書いているので、興味があればご覧ください。

https://qiita.com/suzuki0430/items/fbcd0ce8ee10f91e3d92

https://qiita.com/suzuki0430/items/6afa25b28d96612aa429

#実装
今回はログインにJWT認証を使用します。
簡単に説明すると、ログインが成功したらサーバからトークンが発行されて、そのトークンをリクエストのヘッダーに埋め込むことで本人であることを確認するといった認証方式です。

https://www.okta.com/jp/identity-101/what-is-token-based-authentication/

実装にあたり、以下を呼び出す2つのカスタムフックを作成します。

- `useUser()`: user / updateUser(キャッシュ更新) / clearUser(キャッシュ削除)
- `useAuth()`: signin / signup / signout

##useUser()
`useUser()`では、クエリキャッシュとローカルストレージのデータ管理を行います。

`useQuery()`の`initialData`オプションに`getStoredUser`を設定することで、ローカルストレージのデータをクエリキャッシュとして保存します。
`initialData`は登録済みのデータをデフォルトでフォームに表示したい場合などに使用します。

また、`onSuccess`オプションでは、クエリによるデータ取得が成功したときや`setQueryData`でキャッシュが更新されたときに、関数(処理)を発火させることができます。
今回のケースでは、データがnull(ユーザが未ログイン)の場合にはローカルストレージのデータも削除(`clearStoreUser()`)して、それ以外の場合にはローカルストレージにデータを登録(`setStoredUser()`)します。

`updateUser`や`clearUser`では、`setQueryData`を使って、それぞれキャッシュの更新と削除処理を行います。

```ts:useUser.ts
import { AxiosResponse } from 'axios';
import { useQuery, useQueryClient } from 'react-query';

import type { User } from '../../../../../shared/types';
import { axiosInstance, getJWTHeader } from '../../../axiosInstance';
import { queryKeys } from '../../../react-query/constants';
import {
  clearStoredUser,
  getStoredUser,
  setStoredUser,
} from '../../../user-storage';

async function getUser(user: User | null): Promise<User | null> {
  if (!user) return null;
  const { data }: AxiosResponse<{ user: User }> = await axiosInstance.get(
    `/user/${user.id}`,
    {
      headers: getJWTHeader(user),
    },
  );
  return data.user;
}

interface UseUser {
  user: User | null;
  updateUser: (user: User) => void;
  clearUser: () => void;
}

export function useUser(): UseUser {
  const queryClient = useQueryClient();
  const { data: user } = useQuery(queryKeys.user, () => getUser(user), {
    initialData: getStoredUser,
    onSuccess: (received: User | null) => {
      if (!received) {
        clearStoredUser();
      } else {
        setStoredUser(received);
      }
    },
  });

  function updateUser(newUser: User): void {
    queryClient.setQueryData(queryKeys.user, newUser);
  }

  function clearUser() {
    queryClient.setQueryData(queryKeys.user, null);
    queryClient.removeQueries('user-appointments');
  }

  return { user, updateUser, clearUser };
}
```
ローカルストレージのデータ取得/保存/削除処理は以下のようになっています。

```ts:index.ts
import { User } from '../../../shared/types';

const USER_LOCALSTORAGE_KEY = 'lazyday_user';

export function getStoredUser(): User | null {
  const storedUser = localStorage.getItem(USER_LOCALSTORAGE_KEY);
  return storedUser ? JSON.parse(storedUser) : null;
}

export function setStoredUser(user: User): void {
  localStorage.setItem(USER_LOCALSTORAGE_KEY, JSON.stringify(user));
}

export function clearStoredUser(): void {
  localStorage.removeItem(USER_LOCALSTORAGE_KEY);
}
```

##useAuth()
作成した`userUser()`を`userAuth()`内で呼び出し、`updateUser`と`clearUser`を使用します。
`authServerCall`内でログイン処理を行っており(サインアップ処理は未実装)、サーバから`token`を取得できれば`updateUser()`でクエリキャッシュ(およびローカルストレージ)も更新します。

```ts:useAuth.ts
import { axiosInstance } from '../axiosInstance';
import { useCustomToast } from '../components/app/hooks/useCustomToast';
import { useUser } from '../components/user/hooks/useUser';

interface UseAuth {
  signin: (email: string, password: string) => Promise<void>;
  signup: (email: string, password: string) => Promise<void>;
  signout: () => void;
}

export function useAuth(): UseAuth {
  const SERVER_ERROR = 'There was an error contacting the server.';
  const toast = useCustomToast();
  const { clearUser, updateUser } = useUser();

  async function authServerCall(
    urlEndpoint: string,
    email: string,
    password: string,
  ): Promise<void> {
    try {
      const { data, status } = await axiosInstance({
        url: urlEndpoint,
        method: 'POST',
        data: { email, password },
        headers: { 'Content-Type': 'application/json' },
      });

      if (status === 400) {
        toast({ title: data.message, status: 'warning' });
        return;
      }

      if (data?.user?.token) {
        toast({
          title: `Logged in as ${data.user.email}`,
          status: 'info',
        });

        updateUser(data.user);
      }
    } catch (errorResponse) {
      toast({
        title: errorResponse?.response?.data?.message || SERVER_ERROR,
        status: 'error',
      });
    }
  }

  async function signin(email: string, password: string): Promise<void> {
    authServerCall('/signin', email, password);
  }
  async function signup(email: string, password: string): Promise<void> {
    authServerCall('/user', email, password);
  }

  function signout(): void {
    clearUser();
    toast({
      title: 'Logged out!',
      status: 'info',
    });
  }

  return {
    signin,
    signup,
    signout,
  };
}
```
`getJWTHeader`や`AxiosRequestConfig`の中身は以下のようになっています。

```ts:index.ts
interface jwtHeader {
  Authorization?: string;
}

export function getJWTHeader(user: User): jwtHeader {
  return { Authorization: `Bearer ${user.token}` };
}

const config: AxiosRequestConfig = { baseURL: baseUrl };
export const axiosInstance = axios.create(config);
```

#参考資料

https://www.udemy.com/share/104FBU3@ppHAFYOMrp7gkoU1C8-Q4YFUyIVUk34oGXDjebWtEFRrxSuLfEyNEDqPyu5pzMpj/

https://react-query.tanstack.com/reference/useIsFetching