본문 바로가기

FrontEnd

대규모 프로젝트 React Query 아키텍처

반응형

원문 보기

 

Building The Real App With React Query — Smashing Magazine

In this article, Georgii Perepecho explains the most common React Query features that you need to be familiar with when creating a real-life application that is stable when testing.

www.smashingmagazine.com

원문의 완성된 프로젝트 레포지토리

 

GitHub - horprogs/react-query: An example of building the app using React Query.

An example of building the app using React Query. Contribute to horprogs/react-query development by creating an account on GitHub.

github.com

들어가며 : State의 횡단관심사

해당 글에서 State의 종류에 대해 분류하고 있다.

 

아폴로 클라이언트로 알아보는 클라이언트 아키텍처 [Apollo Client & Client-side Architecture Basics]

https://www.apollographql.com/blog/apollo-client/architecture/client-side-architecture-basics/ Apollo Client & Client-side Architecture Basics Architecture is the foundation, the skeleton, that sets..

itchallenger.tistory.com

들어가며 2 :

원문의 코드에 타입 오류가 있는 부분이 있어 수정한 곳이 존재합니다.

또한 예시 프로젝트의 코드량이 상당한데 비해, 원문에 제공된 코드가 적어 이해하기 어려운 부분들이 있습니다.

따라서, 원문 코드를 바로 업로드 하는 대신, 아래 샌드박스의 링크를 제공하도록 하겠습니다.

개인적으로 샌드박스를 열어 링크의 url을 따라가며, 파일을 직접 열어보면서 학습하시길 추천합니다.

Example app에서 원래 github repository에 접근할 수 있습니다.


서론

비동기 데이터를 사용하는 React 애플리케이션을 개발한 적이 있다면,
  • 서로 다른 종류의 상태(로딩, 오류 등)를 처리하고,
  • 동일한 API 엔드포인트를 사용하여 컴포넌트 간 상태를 동기화 및 공유하는 것

이 얼마나 귀찮은 일인지 알 것입니다.

 
데이터를 새로 고치려면
  • useState 및 useEffect 훅을 사용하고,
  • API에서 데이터를 가져오고,
  • 업데이트된 데이터를 상태에 넣고,
  • 로드 상태를 변경하고,
  • 오류를 처리하는
  • 등 많은 작업을 수행해야 합니다.
데이터 가져오기, 데이터 관리, 데이터 캐싱, 데이터 관리를 더 쉽게 해주는 라이브러리인 React Query가 있습니다.

Benefits Of Using The New Approach

React Query에는 다음과 같은 훌륭한 기능들이 있습니다.
  • 캐싱
  • 동일한 데이터에 대한 여러 요청을 단일 요청으로 중복 제거(deduping)
  • 백그라운드에서 "오래된" 데이터 업데이트(창 포커스 시, 재연결 시, 일정 주기 등)
  • 페이지네이션 및 지연 로딩과 같은 성능 최적화;
  • 쿼리 결과를 메모이제이션
  • 데이터 프리페칭
  • Optimistic UI를 쉽게 구현할 수 있는 mutation(변이).
예제 응용 프로그램에서 모든 경우를 다룹니다.
애플리케이션은 TypeScript로 작성되었으며
쉬운 프로토타이핑을 위해 CRA, React Query, Axios mock server 및 , material UI를 사용합니다.

Demonstration The Example Application

자동차 수리 서비스 시스템을 구현하고 싶다고 가정해 보겠습니다. 다음을 수행할 수 있어야 합니다.
  • 이메일과 비밀번호를 사용하여 로그인하고 로그인한 사용자를 표시합니다.
  • 다음 예약 목록을 표시합니다.
  • 하나의 특정 예약에 대한 정보를 표시합니다.
  • 변경 히스토리를 저장하고 조회합니다.
  • 추가 정보를 미리 가져옵니다.
  • 필요한 작업을 추가하고 수정합니다.

Client-Side Interaction

실제 백엔드 서버가 없으므로 axios-mock-adapter를 사용합니다.
get/post/patch/delete endpoint로 일종의 REST API 역할을 합니다.
데이터를 저장하기 위해 우리는 fixture를 사용할 것입니다.
(특별한 것은 없습니다. 단지 우리가 변경할 변수일 뿐입니다.)

또한 상태 변화를 볼 수 있도록 요청당 지연 시간을 1초로 설정했습니다.


Preparing React Query For Using

이제 React Query를 설정할 준비가 되었습니다.

먼저 앱을 provider로 래핑해야 합니다.

QueryClient()에서 일부 전역 기본값을 지정할 수 있습니다.

더 쉬운 개발을 위해 React Query 훅에 대한 자체적인 추상화를 만들 것입니다.

쿼리를 구독하려면 고유 키를 전달해야 합니다.

문자열을 사용하는 가장 쉬운 방법이지만 배열(arraylike)키를 사용하는 것도 가능합니다.

 

공식 문서에서는 문자열 키를 사용하지만,

URL을 키로 사용할 수 있으므로 키에 대한 새 문자열을 만들 필요가 없습니다.

 

하지만 GET/PATCH에 대해 다른 URL을 사용하려는 경우에도 동일한 키를 사용해야 합니다.

그렇지 않으면 React Query가 이러한 쿼리를 동기화할 수 없습니다.

 

또한 URL뿐만 아니라 백엔드에 요청하는 데 사용할 모든 파라미터를 포함하는 것이 중요하다는 점을 명심해야 합니다.

URL과 params의 조합은 React Query가 캐싱에 사용할 키를 생성합니다.
데이터 가져오기 도구로 Axios를 사용하여 queryKey의 URL과 매개변수를 전달합니다.
[url!, params]가 키이고 enabled: !!url은 키가 없는 경우 요청을 일시 중지하는 데 사용합니다.
fetcher의 경우 무엇이든 사용할 수 있습니다. 저는 Axios를 선택했습니다.
더 나은 개발자 경험을 위해 루트 컴포넌트에서 React Query Devtools를 사용할 수 있습니다.

React Query devtools


Authentication

앱을 사용하려면 이메일과 비밀번호를 입력하여 로그인해야 합니다.
서버는 토큰을 반환하고 쿠키에 저장합니다(예제 앱에서는 이메일/비밀번호 조합).
사용자가 앱을 둘러볼 때 각 요청에 토큰을 첨부합니다.
또한 토큰으로 사용자 프로필을 가져옵니다.
요청이 아직 진행 중인 경우 헤더에 사용자 이름이나  로드가 표시됩니다.
흥미로운 부분은 루트 App 컴포넌트에서 로그인 페이지로의 리디렉션을 처리할 수 있지만
별도의 컴포넌트에 사용자 이름을 표시할 수 있다는 것입니다.

여기에서 React Query 매직이 시작됩니다.

hook을 사용하면 props로 전달하지 않고도 사용자 데이터를 쉽게 공유할 수 있습니다.

로그인을 처리하는 App.tsx

로딩 상태 표시 / 인증 상태를 표시하는 UserProrile.tsx

API에 대한 요청은 한 번만 호출됩니다(deduping requests이라고 하며 다음 섹션에서 좀 더 자세히 설명하겠습니다).
profile data를 가져오는 훅입니다. (useGetProfile)

이 요청을 재시도하고 싶지 않기 때문에 retry: false 설정을 사용합니다.

실패하면 사용자가 권한이 없다고 판단하여 리디렉션을 수행합니다.

 

사용자가 로그인과 비밀번호를 입력하면 정기적인 POST 요청을 보냅니다.

이론적으로 여기에서 React Query mutation을 사용할 수도 있습니다.

 

요청이 성공하면 모든 쿼리를 무효화하여 새로운 데이터를 얻습니다.

우리 앱에서는 헤더의 이름을 업데이트하기 위한 사용자 프로필과 모든 것을 무효화하는 단 하나의 쿼리일 것입니다.

  • https://codesandbox.io/s/react-query-example-30bd54?file=/src/pages/Auth.tsx
if (resp.data.token) {
 Cookies.set('token', resp.data.token);
 history.replace(pageRoutes.main);
 queryClient.invalidateQueries();
}
주 : 코드샌드박스에서 프로젝트를 새창으로 띄우고 아무 패스워드, 비밀번호나 입력하면 로그인 됩니다.

More About Deduping Requests

동일한 API 엔드포인트를 사용하는 두 개의 다른(또는 동일한) 컴포넌트가 페이지에 있다고 가정해 보겠습니다.
동일한 요청을 두 번 하면 이는 백엔드 리소스의 낭비일 뿐입니다.
React Query를 사용하여 동일한 매개변수로 API 호출을 중복 제거할 수 있습니다.
즉, 동일한 요청을 하는 컴포넌트가 있는 경우 요청이 한 번만 수행됩니다.
 
우리 앱에는 모든 약속 목록을 보여주는 두 가지 컴포넌트가 있습니다.
동일한 hook useGetAppointmentsList()를 사용합니다.
GET /api/getUserList 요청은 한 번만 호출됩니다.

Load More List

Load More 버튼이 있는 무한 목록(infinite list)이 있습니다.

일반 useQuery 훅을 사용하여 구현할 수 없기 때문에,

useInfiniteQuery 훅이 있으며 fetchNextPage 함수를 사용하여 페이지네이션을 처리할 수 있습니다.

export const useGetAppointmentsList = () =>
 useLoadMore<AppointmentInterface[]>(apiRoutes.getUserList);

React Query 훅에 대한 자체 추상화를 사용합니다.

useFetch 훅과 거의 동일하지만,

API 응답을 기반으로 getPreviousPageParam 및 getNextPageParam 함수를 지정하고

pageParam 속성을 fetcher 함수에 전달합니다.

export const useLoadMore = <T>(url: string | null, params?: object) => {
 const context = useInfiniteQuery<
   GetInfinitePagesInterface<T>,
   Error,
   GetInfinitePagesInterface<T>,
   QueryKeyT
 >(
   [url!, params],
   ({ queryKey, pageParam = 1 }) => fetcher({ queryKey, pageParam }),
   {
     getPreviousPageParam: (firstPage) => firstPage.previousId ?? false,
     getNextPageParam: (lastPage) => {
       return lastPage.nextId ?? false;
     },
   }
 );

 return context;
};

사용처 

useInfiniteQuery 훅에는 
  • hasNextPage, isFetchingNextPage와 같은 몇 가지 추가 필드가 있습니다.
    • 다음 액션과 관련된 상태입니다.
  • 그리고 메소드 fetchNextPage, fetchPreviousPage가 있습니다.
    • 이전, 이후 페이지를 가져오는 메서드입니다.

Background Fetching Indicator/Refetching

가장 흥미로운 기능 중 하나는 탭 간 전환과 같이 창 포커스를 변경하면 데이터를 다시 가져오는 것입니다.
예를 들어 여러 작성자가 데이터를 변경할 수 있는 경우 유용할 수 있습니다.
이 경우 브라우저 탭을 열어 두면 페이지를 다시 로드할 필요가 없습니다.
창에 초점을 맞추면 실제 데이터가 표시됩니다.
또한 플래그를 사용하여 가져오기가 진행 중임을 나타낼 수 있습니다.
해당 설정들을 on, off 할수 있습니다.
  • refetchInterval,
  • refetchIntervalInBackground,
  • refetchOnMount,
  • refetchOnReconnect,
  • refetchOnWindowFocus.

또한 글로벌 설정도 가능합니다. (@index.tsx)

const queryClient = new QueryClient({
 defaultOptions: {
   queries: {
     refetchOnWindowFocus: false,
   },
 },
});
refetch 상태를 표시하는 isFetching 플래그가 있습니다.

Making Conditional Requests

hooks를 사용한 조건부 요청은 어떻게 구현할까요?

아시다시피 훅이 있는 조건문은 사용할 수 없습니다.

예를 들어 다음과 같이 코딩할 수는 없습니다.

if (data?.hasInsurance) {
 const { data: insurance } = useGetInsurance(
   data?.hasInsurance ? +id : null
 );
}
appointment endpoint 응답을 기반으로 보험 세부 정보를 얻기 위해 추가 요청을 해야 한다고 가정해 보겠습니다.

 

요청을 하려면 키를 전달하고 그렇지 않으면 null을 전달합니다.
const { data: insurance } = useGetInsurance(data?.hasInsurance ? +id : null);

export const useGetInsurance = (id: number | null) =>
 useFetch<InsuranceDetailsInterface>(
   id ? pathToUrl(apiRoutes.getInsurance, { id }) : null
 );
useFetch 추상화에서 키가 없는 경우 설정에서에서 enabled 속성을 false로 설정했었습니다.
export const useFetch = <T>(
  url: string | null,
  params?: object,
  config?: UseQueryOptions<T, Error, T, QueryKeyT>
) => {
  const context = useQuery<T, Error, T, QueryKeyT>(
    [url!, params],
    ({ queryKey }) => fetcher({ queryKey }),
    {
      enabled: !!url,
      ...config
    }
  );

  return context;
};
이 경우 React Query는 요청을 일시 중지합니다.

id = 1인 appointment의 경우 hasInsurance = true입니다.

다음으로 다른 요청을 하고 이름 옆에 체크 아이콘을 표시합니다.

이는 getInsurance 엔드포인트에서 allCovered 플래그를 수신했음을 의미합니다.



id = 2인 appointment의 경우 hasInsurance = false이고 보험 세부 정보를 요청하지 않습니다.
/getInsulance 요청 안함

Simple Mutation With Data Invalidation

React Query에서 데이터를 CREATE / UPDATE / DELETE하기 위해 mutation을 사용합니다.

  • 서버에 요청을 보내고,
  • 응답을 받고,
  • 정의된 업데이터 함수를 기반으로 상태를 변경하고
  • 추가 요청 없이 최신 상태를 유지한다는 것을 의미합니다.

우리는 이러한 행동에 대해 일반적인 추상화를 구현하였습니다.

/src/utils/reactQuery.ts > useGenericMutation

const useGenericMutation = <T, S>(
 func: (data: S) => Promise<AxiosResponse<S>>,
 url: string,
 params?: object,
 updater?: ((oldData: T, newData: S) => T) | undefined
) => {
 const queryClient = useQueryClient();

 return useMutation<AxiosResponse, AxiosError, S>(func, {
   onMutate: async (data) => {
     await queryClient.cancelQueries([url!, params]);

     const previousData = queryClient.getQueryData([url!, params]);

queryClient.setQueryData<T>([url!, params], (oldData) => {
 return updater ? (oldData!, data) : data;
});


     return previousData;
   },
   // If the mutation fails, use the context returned from onMutate to roll back
   onError: (err, _, context) => {
     queryClient.setQueryData([url!, params], context);
   },

   onSettled: () => {
     queryClient.invalidateQueries([url!, params]);
   },
 });
};​

좀 더 자세히 살펴보겠습니다. 몇 가지 콜백 메서드가 있습니다.

onMutate(요청이 성공한 경우):
  1. 진행 중인 모든 요청을 취소합니다.
  2. 현재 데이터를 변수에 저장합니다.
  3. updater 함수를 사용하여 특정 논리로 상태를 변경하거나, 새 데이터로 상태를 재정의합니다.
    1. 대부분의 경우 업데이터 기능을 정의하는 것이 좋습니다.
  4. 이전 데이터를 반환합니다.
onError(요청이 실패한 경우):
  1. 이전 데이터로 롤백합니다
onSettled(요청이 성공하거나 실패한 경우):
  1. 최신 상태를 유지하기 위해 쿼리를 무효화합니다.
이 추상화는 모든 변이 작업에 사용합니다.
export const useDelete = <T>(
 url: string,
 params?: object,
 updater?: (oldData: T, id: string | number) => T
) => {
 return useGenericMutation<T, string | number>(
   (id) => api.delete(`${url}/${id}`),
   url,
   params,
   updater
 );
};

export const usePost = <T, S>(
 url: string,
 params?: object,
 updater?: (oldData: T, newData: S) => T
) => {
 return useGenericMutation<T, S>(
   (data) => api.post<S>(url, data),
   url,
   params,
   updater
 );
};

export const useUpdate = <T, S>(
 url: string,
 params?: object,
 updater?: (oldData: T, newData: S) => T
) => {
 return useGenericMutation<T, S>(
   (data) => api.patch<S>(url, data),
   url,
   params,
   updater
 );
};

모든 훅에서에서 동일한 [url!, params] 세트(우리가 키로 사용함)를 이용하는 것이 매우 중요합니다.

그렇지 않으면, 라이브러리는 상태를 무효화하고 쿼리를 동기화할 수 없습니다.

 

앱에서 어떻게 동작하는지 봅시다. History Section이 있습니다.

저장 버튼을 클릭하면 PATCH 요청을 보내고 업데이트된 전체 appointment 개체를 받습니다.

 

먼저 mutation을 정의합니다.

지금은 복잡한 논리를 수행하지 않고 새 상태를 반환하기만 하므로 업데이터 함수를 지정하지 않습니다.

/src/api/appointments.ts 

const mutation = usePatchAppointment(+id);

export const usePatchAppointment = (id: number) =>
 useUpdate<AppointmentInterface, AppointmentInterface>(
   pathToUrl(apiRoutes.appointment, { id })
 );
 
마지막으로 patch하려는 데이터로 mutate 메서드를 호출합니다.
mutation.mutate([data!])
참고: 컴포넌트에서 isFetching 플래그를 사용하여 창 포커스에 대한 데이터 업데이트를 나타내므로(Background fetching 섹션 확인) 요청이 진행 중일 때마다 로드 상태를 표시합니다.
Save을 클릭하면 상태를 변경하고 실제 응답을 가져오기 때문에 로드 상태도 표시합니다.

Mutation With Optimistic Changes

이제 더 복잡한 예를 살펴보겠습니다.
앱에서 항목을 추가하고 제거할 수 있는 목록이 필요합니다.
또한 사용자 경험을 최대한 원활하게 만들고자 합니다.
우리는 job 생성/삭제에 대한 낙관적인 변경을 구현할 것입니다.
 
사용자의 액션은 다음과 같습니다.
 
  1. 사용자는 job name을 입력하고 Add 버튼을 클릭합니다.
  2. 이 항목을 즉시 목록에 추가하고 Add 버튼에 로더를 표시합니다.
  3. 동시에 API에 요청을 보냅니다.
  4. 응답이 수신되면 로더를 숨기고
    1. 성공하면 이전 항목을 유지하고 목록에서 ID를 업데이트하고 입력 필드를 지웁니다.
    2. 응답이 실패하면 오류 알림을 표시하고 목록에서 이 항목을 제거하고 입력 필드를 이전 값으로 유지합니다.
  5. 두 경우 모두 실제 상태가 있는지 확인하기 위해 API에 GET 요청을 보냅니다.
로직은 다음과 같습니다.

/src/components/Jobs/Jobs.tsx

const { data, isLoading } = useGetJobs();

const mutationAdd = useAddJob((oldData, newData) => [...oldData, newData]);
const mutationDelete = useDeleteJob((oldData, id) =>
 oldData.filter((item) => item.id !== id)
);

const onAdd = async () => {
 try {
   await mutationAdd.mutateAsync({
     name: jobName,
     appointmentId,
   });
   setJobName('');
 } catch (e) {
   pushNotification(`Cannot add the job: ${jobName}`);
 }
};

const onDelete = async (id: number) => {
 try {
   await mutationDelete.mutateAsync(id);
 } catch (e) {
   pushNotification(`Cannot delete the job`);
 }
};
이 예제에서 우리는 사용자 정의 로직으로 상태를 변경하는 자체 업데이터 함수를 정의합니다.
우리에게는 새 항목으로 배열을 만들고 항목을 삭제하려는 경우 id로 필터링하는 것입니다.
그러나 논리는 무엇이든 될 수 있으며 작업에 따라 다릅니다.
 
React Query는 상태 변경, 요청 생성, 문제 발생 시 이전 상태 롤백을 처리합니다.
콘솔에서 axios가 모의 API에 대해 어떤 요청을 하는지 확인할 수 있습니다.
UI에서 업데이트된 목록을 즉시 볼 수 있으며 POST를 호출하고 마지막으로 GET을 호출합니다.
useGenericMutation hook에서 onSettled 콜백을 정의했기 때문에, 성공 또는 오류 후에 항상 데이터를 다시 가져옵니다.
onSettled: () => {
 queryClient.invalidateQueries([url!, params]);
},​
참고: devtool에서 작업하다 돌아오면 많은 요청을 볼 수 있습니다.
Dev Tools 창을 클릭하면 창 포커스를 변경하고, React Query가 상태를 무효화하기 때문입니다.

백엔드가 오류를 반환하면 낙관적인 변경 사항을 롤백하고 알림을 표시합니다.

useGenericMutation hook에서 onError 콜백을 정의했기 때문에 오류가 발생하면 이전 데이터를 설정합니다.

onError: (err, _, context) => {
 queryClient.setQueryData([url!, params], context);
},

Prefetching

사용자가 가까운 장래에 이 데이터를 요청할 가능성이 높은 경우 프리페치가 유용할 수 있습니다.

예시에서는 사용자가 추가 섹션 영역에서 마우스 커서를 위치하면, 자동차 세부 정보를 미리 가져옵니다.

사용자가 Show 버튼을 클릭하면 API를 호출하지 않고 데이터를 즉시 렌더링합니다(1초 지연에도 불구하고).

 

/src/pages/Appointment.tsx 

const prefetchCarDetails = usePrefetchCarDetails(+id);

onMouseEnter={() => {
 if (!prefetched.current) {
   prefetchCarDetails();
   prefetched.current = true;
 }
}}

export const usePrefetchCarDetails = (id: number | null) =>
 usePrefetch<InsuranceDetailsInterface>(
   id ? pathToUrl(apiRoutes.getCarDetail, { id }) : null
 );

프리페칭을 위한 추상화 훅이 있습니다.

export const usePrefetch = <T>(url: string | null, params?: object) => {
 const queryClient = useQueryClient();

 return () => {
   if (!url) {
     return;
   }

   queryClient.prefetchQuery<T, Error, T, QueryKeyT>(
     [url!, params],
     ({ queryKey }) => fetcher({ queryKey })
   );
 };
};
자동차 세부 정보를 렌더링하기 위해 CarDetail 컴포넌트를 사용합니다.
데이터 검색을 위한 훅을 정의합니다.

/src/components/CarDetails/CarDetails.tsx

const CarDetails = ({ id }: Props) => {
 const { data, isLoading } = useGetCarDetail(id);

 if (isLoading) {
   return <CircularProgress />;
 }

 if (!data) {
   return <span>Nothing found</span>;
 }

 return (
   <Box>
     <Box mt={2}>
       <Typography>Model: {data.model}</Typography>
     </Box>

     <Box mt={2}>
       <Typography>Number: {data.number}</Typography>
     </Box>
   </Box>
 );
};

export const useGetCarDetail = (id: number | null) =>
 useFetch<CarDetailInterface>(
   pathToUrl(apiRoutes.getCarDetail, { id }),
   undefined,
   { staleTime: 2000 }
 );

이 컴포넌트에 추가 props를 전달할 필요가 없다는 점은 좋은 점입니다.

따라서 Appointment 컴포넌트에서 데이터를 미리 가져오고

CarDetails 컴포넌트에서 prefetche 된 데이터를 검색하기 위해 GetCarDetail 훅을 사용합니다.

 

확장된 staleTime을 설정하여 사용자가 표시 버튼을 클릭하기 전에 더 많은 시간 동안 데이터를 유지할 수 있습니다.
이 설정이 없으면 프리페칭 영역에서 커서를 이동한 후, 버튼을 바로 클릭하지 않고 시간을 오래 보내면, 요청이 두 번 호출될 수 있습니다.

Suspense

Suspense는 선언적인 방식으로 일부 코드를 기다릴 수 있게 해주는 실험적인 React 기능입니다.
즉, Suspense 컴포넌트를 호출하고 데이터를 기다리는 동안 UI에 표시하려는 fallback 컴포넌트를 정의할 수 있습니다.
React Query의 isLoading 플래그도 필요하지 않습니다. 자세한 내용은 official documentation을 참조하십시오.

Service목록이 있고 오류를 표시하고, 문제가 발생한 경우 Try Again 버튼을 클릭한다고 가정해 보겠습니다.

새로운 훌륭한 개발자 경험을 얻으려면 Suspense, React Query 및 Error Boundaries를 함께 사용합니다.

마지막으로 react-error-boundary 라이브러리를 사용합니다.

src/components/ServicesList/ServicesList.tsx

<QueryErrorResetBoundary>
 {({ reset }) => (
   <ErrorBoundary
     fallbackRender={({ error, resetErrorBoundary }) => (
       <Box width="100%" mt={2}>
         <Alert severity="error">
           <AlertTitle>
             <strong>Error!</strong>
           </AlertTitle>
           {error.message}
         </Alert>

         <Box mt={2}>
           <Button
             variant="contained"
             color="error"
             onClick={() => resetErrorBoundary()}
           >
             Try again
           </Button>
         </Box>
       </Box>
     )}
     onReset={reset}
   >
     <React.Suspense
       fallback={
         <Box width="100%">
           <Box mb={1}>
             <Skeleton variant="text" animation="wave" />
           </Box>
           <Box mb={1}>
             <Skeleton variant="text" animation="wave" />
           </Box>
           <Box mb={1}>
             <Skeleton variant="text" animation="wave" />
           </Box>
         </Box>
       }
     >
       <ServicesCheck checked={checked} onChange={onChange} />
     </React.Suspense>
   </ErrorBoundary>
 )}
</QueryErrorResetBoundary>
Suspense 컴포넌트 내에서 서비스 목록  API 엔드포인트를 호출하는 ServiceCheck 컴포넌트를 렌더링합니다.
const { data } = useGetServices();
훅에서 suspense: true 및 retry: 0을 설정합니다.
export const useGetServices = () =>
 useFetch<ServiceInterface[]>(apiRoutes.getServices, undefined, {
   suspense: true,
   retry: 0,
 });
mock 서버에서 무작위로 200 또는 500 상태 코드의 응답을 보냅니다.
mock.onGet(apiRoutes.getServices).reply((config) => {
 if (!getUser(config)) {
   return [403];
 }

 const failed = !!Math.round(Math.random());

 if (failed) {
   return [500];
 }

 return [200, services];
});
따라서 API에서 오류를 수신하고 처리하지 않으면 응답의 메시지와 함께 빨간색 알림을 표시합니다.
Try Again 버튼을 클릭하면 요청을 다시 호출하는 resetErrorBoundary() 메서드가 호출됩니다.
React Suspense fallback에는 요청 시 렌더링되는 로딩 스켈레톤 컴포넌트가 있습니다.

TRY AGAIN 버튼


 

Testing

로딩 플래그, 데이터 가져오기 및 엔드포인트 호출이 올바르게 작동하는지 확인합니다.

React Query를 사용하여 애플리케이션을 테스트하는 것은 일반 애플리케이션을 테스트하는 것과 거의 동일합니다.

React Testing Library와 Jest를 사용할 것입니다.

 

먼저 렌더링 컴포넌트에 대한 추상화를 만듭니다.
export const renderComponent = (children: React.ReactElement, history: any) => {
 const queryClient = new QueryClient({
   defaultOptions: {
     queries: {
       retry: false,
     },
   },
 });
 const options = render(
   <Router history={history}>
     <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
   </Router>
 );

 return {
   ...options,
   debug: (
     el?: HTMLElement,
     maxLength = 300000,
     opt?: prettyFormat.OptionsReceived
   ) => options.debug(el, maxLength, opt),
 };
};
QueryClient의 기본 설정으로 retry: false를 설정하고 QueryClientProvider로 컴포넌트를 래핑합니다.
이제 Appointment 컴포넌트를 테스트해 보겠습니다.
가장 쉬운 것부터 시작합니다. 바로 컴포넌트가 올바르게 렌더링되는지 확인하는 것입니다.

 

test('should render the main page', async () => {
 const mocked = mockAxiosGetRequests({
   '/api/appointment/1': {
     id: 1,
     name: 'Hector Mckeown',
     appointment_date: '2021-08-25T17:52:48.132Z',
     services: [1, 2],
     address: 'London',
     vehicle: 'FR14ERF',
     comment: 'Car does not work correctly',
     history: [],
     hasInsurance: true,
   },
   '/api/job': [],
   '/api/getServices': [
     {
       id: 1,
       name: 'Replace a cambelt',
     },
     {
       id: 2,
       name: 'Replace oil and filter',
     },
     {
       id: 3,
       name: 'Replace front brake pads and discs',
     },
     {
       id: 4,
       name: 'Replace rare brake pads and discs',
     },
   ],
   '/api/getInsurance/1': {
     allCovered: true,
   },
 });
 const history = createMemoryHistory();
 const { getByText, queryByTestId } = renderComponent(
   <Appointment />,
   history
 );

 expect(queryByTestId('appointment-skeleton')).toBeInTheDocument();

 await waitFor(() => {
   expect(queryByTestId('appointment-skeleton')).not.toBeInTheDocument();
 });

 expect(getByText('Hector Mckeown')).toBeInTheDocument();
 expect(getByText('Replace a cambelt')).toBeInTheDocument();
 expect(getByText('Replace oil and filter')).toBeInTheDocument();
 expect(getByText('Replace front brake pads and discs')).toBeInTheDocument();
 expect(queryByTestId('DoneAllIcon')).toBeInTheDocument();
 expect(
   mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
 ).toBeTruthy();
});
 
Axios 요청을 모킹할 helper 함수를 준비했습니다. 테스트에서 URL과 mock data를 설정할 수 있습니다.

src/utils/testing/axiosMock.ts 

const getMockedData = (
 originalUrl: string,
 mockData: { [url: string]: any },
 type: string
) => {
 const foundUrl = Object.keys(mockData).find((url) =>
   originalUrl.match(new RegExp(`${url}$`))
 );

 if (!foundUrl) {
   return Promise.reject(
     new Error(`Called unmocked api ${type} ${originalUrl}`)
   );
 }

 if (mockData[foundUrl] instanceof Error) {
   return Promise.reject(mockData[foundUrl]);
 }

 return Promise.resolve({ data: mockData[foundUrl] });
};

export const mockAxiosGetRequests = <T extends any>(mockData: {

}): MockedFunction<AxiosInstance> => {
 // @ts-ignore
 return axios.get.mockImplementation((originalUrl) =>
   getMockedData(originalUrl, mockData, 'GET')
 );
};
 
로딩 상태가 존재하는지 확인하고 다음으로 로딩 컴포넌트(스켈레톤)가 마운트 해제될 때까지 기다립니다.
expect(queryByTestId('appointment-skeleton')).toBeInTheDocument();

 await waitFor(() => {
   expect(queryByTestId('appointment-skeleton')).not.toBeInTheDocument();
 });

 

다음으로, 렌더링된 컴포넌트에 필요한 텍스트가 있는지 확인하고
마지막으로 보험 내역(insurance detail)에 대한 API 요청이 호출되었는지 확인합니다.
expect(
   mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
 ).toBeTruthy();
즉, 로딩 플래그, 데이터 가져오기 및 엔드포인트 호출이 올바르게 작동하는지 확인합니다.
다음 텍스트에선 보험 세부 정보가 필요하지 않은 경우 요청을 호출하지 않는지 확인합니다.
(컴포넌트에 조건이 있음을 기억하십시오.
appointment endpoint의 응답에 플래그(hasInsurance: true)가 있는 경우, 
insurance 엔드포인트를 호출해야 합니다.
플래그가 없다면 호출하지 않습니다.
 
 
아래 테스트에선 응답에 hasInsurance: false가 있는 경우 insurance 엔드포인트를 호출하지 않고 아이콘을 렌더링하는지 확인합니다.
test('should not call and render Insurance flag', async () => {
 const mocked = mockAxiosGetRequests({
   '/api/appointment/1': {
     id: 1,
     name: 'Hector Mckeown',
     appointment_date: '2021-08-25T17:52:48.132Z',
     services: [1, 2],
     address: 'London',
     vehicle: 'FR14ERF',
     comment: 'Car does not work correctly',
     history: [],
     hasInsurance: false,
   },
   '/api/getServices': [],
   '/api/job': [],
 });
 const history = createMemoryHistory();
 const { queryByTestId } = renderComponent(<Appointment />, history);

 await waitFor(() => {
   expect(queryByTestId('appointment-skeleton')).not.toBeInTheDocument();
 });

 expect(queryByTestId('DoneAllIcon')).not.toBeInTheDocument();

 expect(
   mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
 ).toBeFalsy();
});
마지막으로 Job 컴포넌트의 변이를 테스트할 것입니다.
test('should be able to add and remove elements', async () => {
 const mockedPost = mockAxiosPostRequests({
   '/api/job': {
     name: 'First item',
     appointmentId: 1,
   },
 });

 const mockedDelete = mockAxiosDeleteRequests({
   '/api/job/1': {},
 });

 const history = createMemoryHistory();
 const { queryByTestId, queryByText } = renderComponent(
   <Jobs appointmentId={1} />,
   history
 );

 await waitFor(() => {
   expect(queryByTestId('loading-skeleton')).not.toBeInTheDocument();
 });

 await changeTextFieldByTestId('input', 'First item');

 await clickByTestId('add');

 mockAxiosGetRequests({
   '/api/job': [
     {
       id: 1,
       name: 'First item',
       appointmentId: 1,
     },
   ],
 });

 await waitFor(() => {
   expect(queryByText('First item')).toBeInTheDocument();
 });

 expect(
   mockedPost.mock.calls.some((item) => item[0] === '/api/job')
 ).toBeTruthy();

 await clickByTestId('delete-1');

 mockAxiosGetRequests({
   '/api/job': [],
 });

 await waitFor(() => {
   expect(queryByText('First item')).not.toBeInTheDocument();
 });

 expect(
   mockedDelete.mock.calls.some((item) => item[0] === '/api/job/1')
 ).toBeTruthy();
});

 

여기서 무슨 일이 일어나고 있는지 봅시다.

  1. POST 및 DELETE에 대한 요청을 모킹합니다.
  2. 필드에 텍스트를 입력하고 버튼을 누릅니다.
  3. POST 요청이 이루어지고, 실제 서버가 업데이트된 데이터를 보내야 한다고 가정(성공을 가정)하기 때문에 GET 끝점을 다시 모킹합니다.
    • 이 경우 1개의 항목이 있는 리스트입니다.
  4. 렌더링된 컴포넌트에서 업데이트된 텍스트를 기다립니다.
  5. api/job에 대한 POST 요청이 호출되었는지 확인합니다.
  6. 삭제 버튼을 클릭합니다.
  7. 빈 리스트 데이터로 GET 끝점을 다시 모킹합니다(이전의 경우 서버에서 삭제 후 업데이트된 데이터를 보낸 것으로 가정).
  8. 삭제된 항목이 문서에 존재하지 않는지 확인하세요.
  9. api/job/1에 대한 DELETE 요청이 호출되었는지 확인합니다.
 
중요!: 각 테스트 후에 모든 모의 객체(mocks)를 지워 결과가 섞이지 않도록 합니다.
afterEach(() => {
 jest.clearAllMocks();
});

Conclusion

우리는 일반적인 React Query 기능을 모두 살펴보았습니다.
 
  • 데이터 가져오기,
  • 상태 관리,
  • 컴포넌트 간 데이터 공유,
  • 낙관적 변경 및 무한 목록을 더 쉽게 구현하는 방법,
  • 테스트로 안정적인 앱을 만드는 방법을 배웠습니다. 

해당 글의 설명이 현재 또는 향후 프로젝트에서 해당 접근 방식을 시도하는 데 확신을 주었길 바랍니다.


RESOURCES

 

반응형