본문 바로가기

온라인 강의(유데미, 인프런 등)/React-Query(유데미)

React Query와 인증 & setQueryData & initialData

반응형

useAuth & useUser

 

useAuth 훅은 signin/signup/signout 함수를 제공하여 서버에 있는 사용자를 인증한다. 유저 데이터 저장은 리액트 쿼리에서 하고(이를 위해 'useUser'라는 훅이 필요하다) 서버를 호출할 때 useAuth가 사용자 데이터를 수집하여 이를 보조하는 것이다.

useUser 훅은 로컬 스토리지와 쿼리 캐시에서의 유저의 상태를 유지한다. useUser는 로컬스토리지의 데이터를 로딩하여 초기 설정을 한고 유저 데이터가 변하면 리액트 쿼리의 useQuery 훅을 사용하여 서버에서부터 최신 데이터를 가져온다. (useQuery 인스턴스의 쿼리 함수가 로그인 유저의 id와 함께 서버에 요청을 보내면 서버가 그 사용자에 대한 데이터를 보내준다. 만약 로그인 한 사용자가 없다면 쿼리 함수가 null을 반환한다.)

즉, useUser의 역할은 로그인한 사용자를 추적하고 정보가 업데이트 되면(로그인, 로그아웃, 유저 정보 변경) setQueryData직접 리액트 캐시를 업데이트 하고 onSuccess 콜백에서 로컬 스토리지를 업데이트 하는 것이다.

전체 예시 코드는 다음과 같다.

// 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';

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

  return data.user;
}

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

export function useUser(): UseUser {
  const queryClient = useQueryClient();

  // call useQuery to update user data from server
  const { data: user } = useQuery<User>(
    queryKeys.user,
    ({ signal }) => getUser(user, signal),
    // ALTERNATE query function to maintain user after mutation
    // (see https://www.udemy.com/course/learn-react-query/learn/#questions/17098438/
    // for discussion)
    // ({ signal }) => {
    //   const storedUser = getStoredUser();
    //   const currentUser = user ?? storedUser;
    //   return getUser(currentUser, signal);
    // },
    {
      // populate initially with user in localStorage
      initialData: getStoredUser,

      // note: onSuccess is called on both successful query function completion
      //     *and* on queryClient.setQueryData
      // the `received` argument to onSuccess will be:
      //    - null, if this is called on queryClient.setQueryData in clearUser()
      //    - User, if this is called from queryClient.setQueryData in updateUser()
      //         *or* from the getUser query function call
      onSuccess: (received: null | User) => {
        if (!received) {
          clearStoredUser();
        } else {
          setStoredUser(received);
        }
      },
    }
  );

  // meant to be called from useAuth
  function updateUser(newUser: User): void {
    // update the user
    queryClient.setQueryData(queryKeys.user, newUser);
  }

  // meant to be called from useAuth
  function clearUser() {
    // reset user to null
    queryClient.setQueryData(queryKeys.user, null);

    // remove user appointments query
    queryClient.removeQueries([queryKeys.appointments, queryKeys.user]);
  }

  return { user, updateUser, clearUser };
}
// useAuth.ts

import axios, { AxiosResponse } from 'axios';

import { User } from '../../../shared/types';
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;
}

type UserResponse = { user: User };
type ErrorResponse = { message: string };
type AuthResponseType = UserResponse | ErrorResponse;

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 }: AxiosResponse<AuthResponseType> =
        await axiosInstance({
          url: urlEndpoint,
          method: 'POST',
          data: { email, password },
          headers: { 'Content-Type': 'application/json' },
        });

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

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

        // update stored user data
        updateUser(data.user);
      }
    } catch (errorResponse) {
      const title =
        axios.isAxiosError(errorResponse) &&
        errorResponse?.response?.data?.message
          ? errorResponse?.response?.data?.message
          : SERVER_ERROR;
      toast({
        title,
        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 {
    // clear user from stored user data
    clearUser();
    toast({
      title: 'Logged out!',
      status: 'info',
    });
  }

  // Return the user object and auth methods
  return {
    signin,
    signup,
    signout,
  };
}

 

setQueryData

 

리액트 쿼리는 인증 제공자 역할을 하며 캐시는 user 값을 필요로 하는 모든 컴포넌트에 user 값을 제공한다. 쿼리 캐시에 값을 설정하기 위해 queryClient.setQueryData를 사용한다. 쿼리와 값을 가져와 쿼리 캐시에 해당 키에 대한 값을 설정할 수 있다. 쿼리 함수와 작동 방식은 유사하지만 여기서는 쿼리 함수 없이 직접 설정한다는 차이점이 있다.

const queryClient = useQueryClient();
queryClient.setQueryData(쿼리 키, 값);

useUser는 두 가지 역할이 있다.

  1. 초기 실행 시 로컬 스토리지에서 데이터를 로드하여 페이지를 새로 고침해도 리액트 쿼리가 사용자를 잃지 않도록 한다.
  2. 로컬 스토리지에 유저 데이터를 저장하기 위해 useQuery의 onSuccess 콜백 안에서 유저 데이터를 업데이트 해야 한다. 

onSuccess 콜백은 useAuth 함수 작업 시 사용하는 queryClient.setQueryData 실행 이후나 쿼리 함수가 반환된 후에 실행된다. 그러므로 useUser에서 로컬 스토리지를 업데이트 하면 둘 중 하나의 방법(queryClient.setQueryData 실행 이후나 쿼리 함수가 반환된 후)으로 캐시와 로컬 스토리지를 업데이트 할 수 있다.

즉, onSuccess는 setQueryData 또는 쿼리 함수 둘 다에서 실행되며 둘 다에서 데이터를 가져올 수 있다. 

      onSuccess: (received: null | User) => {
        if (!received) {
          clearStoredUser();
        } else {
          setStoredUser(received);
        }
      },

 

initialData

 

이렇게 되면 로컬 스토리지에 데이터는 잘 저장 되지만 새로 고침시 로그인이 유지되지 않는다. 이는 페이지를 새로 고침할 때와 같이 useQuery가 초기화를 실행할 때 이 데이터가 로컬 스토리지에 정의되어 있는지 확인하기 때문이다. 이를 위해 useQuery에서 initialData 옵션을 사용한다.

initialData는 쿼리를 초기 데이터로 미리 채우는 방법이다.

이렇게 하면 초기 데이터가 필요할 때마다 getStoredUser 함수가 실행되어 데이터가 가져와진다.

 const { data: user } = useQuery<User>(
    queryKeys.user,
    ({ signal }) => getUser(user, signal),
    {
      // populate initially with user in localStorage
      initialData: getStoredUser,
      onSuccess: (received: null | User) => {
        if (!received) {
          clearStoredUser();
        } else {
          setStoredUser(received);
        }
      },
    }
  );
  
  
// getStoredUser 함수
  
  // helper to get user from localstorage
export function getStoredUser(): User | null {
  const storedUser = localStorage.getItem(USER_LOCALSTORAGE_KEY);
  return storedUser ? JSON.parse(storedUser) : null;
}

 

dependent query(의존적 쿼리)

 

의존적 쿼리란 해당 쿼리를 수행할지 여부를 설정하는 것이다. 예를 들어 유저의 예약 정보 쿼리가 유저 데이터 상태에 의존적인 것처럼 말이다.

useQuery({
	enabled: boolean;
})

예시 코드를 살펴보자. 유저 데이터가 있을 때만 유저 예약 정보를 가져오게 한다.

import { useQuery } from 'react-query';

import type { Appointment, User } from '../../../../../shared/types';
import { axiosInstance, getJWTHeader } from '../../../axiosInstance';
import { queryKeys } from '../../../react-query/constants';
import { useUser } from './useUser';

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

export function useUserAppointments(): Appointment[] {
  const { user } = useUser();

  const fallback: Appointment[] = [];
  const { data: userAppointments = fallback } = useQuery(
    "queryKeys.appointments",
    () => getUserAppointments(user),
    {
      enabled: !!user,
    }
  );

  return userAppointments;
}

 

removeQueries

 

사용자가 로그아웃을 하면 예약정보에 대한 쿼리 데이터가 제거되어야 한다. 위에서 작성한 코드를 다시 가져와보자.

왜 clearUser 함수에서 왜 user 데이터에 removeQueries를 사용하지 않고 setQueryData에 null을 사용할까?

  const { data: user } = useQuery<User>(
    queryKeys.user,
    ({ signal }) => getUser(user, signal),
    {
      // populate initially with user in localStorage
      initialData: getStoredUser,
      onSuccess: (received: null | User) => {
        if (!received) {
          clearStoredUser();
        } else {
          setStoredUser(received);
        }
      },
    }
  );
  
  // meant to be called from useAuth
  function clearUser() {
    // reset user to null
    // useQuery의 onSuccess를 trigger하여 스토리지에서 유저 데이터를 지운다.
    queryClient.setQueryData(queryKeys.user, null);

    // remove user appointments query
    queryClient.removeQueries("queryKeys.appointments");
  }

이는 setQueryData가 onSuccess를 발생시키기 때문이다. onSuccess는 setQueryData 다음에는 실행되지만 removeQueries 다음에는 실행되지 않는다. 

const queryClient = useQueryClient();
queryClient.removeQueries();

 

반응형