원문 보기
원문의 완성된 프로젝트 레포지토리
들어가며 : State의 횡단관심사
해당 글에서 State의 종류에 대해 분류하고 있다.
들어가며 2 :
원문의 코드에 타입 오류가 있는 부분이 있어 수정한 곳이 존재합니다.
또한 예시 프로젝트의 코드량이 상당한데 비해, 원문에 제공된 코드가 적어 이해하기 어려운 부분들이 있습니다.
따라서, 원문 코드를 바로 업로드 하는 대신, 아래 샌드박스의 링크를 제공하도록 하겠습니다.
개인적으로 샌드박스를 열어 링크의 url을 따라가며, 파일을 직접 열어보면서 학습하시길 추천합니다.
Example app에서 원래 github repository에 접근할 수 있습니다.
서론
- 서로 다른 종류의 상태(로딩, 오류 등)를 처리하고,
- 동일한 API 엔드포인트를 사용하여 컴포넌트 간 상태를 동기화 및 공유하는 것
이 얼마나 귀찮은 일인지 알 것입니다.
- useState 및 useEffect 훅을 사용하고,
- API에서 데이터를 가져오고,
- 업데이트된 데이터를 상태에 넣고,
- 로드 상태를 변경하고,
- 오류를 처리하는
- 등 많은 작업을 수행해야 합니다.
Benefits Of Using The New Approach
- 캐싱
- 동일한 데이터에 대한 여러 요청을 단일 요청으로 중복 제거(deduping)
- 백그라운드에서 "오래된" 데이터 업데이트(창 포커스 시, 재연결 시, 일정 주기 등)
- 페이지네이션 및 지연 로딩과 같은 성능 최적화;
- 쿼리 결과를 메모이제이션
- 데이터 프리페칭
- Optimistic UI를 쉽게 구현할 수 있는 mutation(변이).
Demonstration The Example Application
- 이메일과 비밀번호를 사용하여 로그인하고 로그인한 사용자를 표시합니다.
- 다음 예약 목록을 표시합니다.
- 하나의 특정 예약에 대한 정보를 표시합니다.
- 변경 히스토리를 저장하고 조회합니다.
- 추가 정보를 미리 가져옵니다.
- 필요한 작업을 추가하고 수정합니다.
Client-Side Interaction
또한 상태 변화를 볼 수 있도록 요청당 지연 시간을 1초로 설정했습니다.
Preparing React Query For Using
이제 React Query를 설정할 준비가 되었습니다.
먼저 앱을 provider로 래핑해야 합니다.
더 쉬운 개발을 위해 React Query 훅에 대한 자체적인 추상화를 만들 것입니다.
쿼리를 구독하려면 고유 키를 전달해야 합니다.
문자열을 사용하는 가장 쉬운 방법이지만 배열(arraylike)키를 사용하는 것도 가능합니다.
공식 문서에서는 문자열 키를 사용하지만,
URL을 키로 사용할 수 있으므로 키에 대한 새 문자열을 만들 필요가 없습니다.
하지만 GET/PATCH에 대해 다른 URL을 사용하려는 경우에도 동일한 키를 사용해야 합니다.
그렇지 않으면 React Query가 이러한 쿼리를 동기화할 수 없습니다.
또한 URL뿐만 아니라 백엔드에 요청하는 데 사용할 모든 파라미터를 포함하는 것이 중요하다는 점을 명심해야 합니다.
Authentication
여기에서 React Query 매직이 시작됩니다.
hook을 사용하면 props로 전달하지 않고도 사용자 데이터를 쉽게 공유할 수 있습니다.
로딩 상태 표시 / 인증 상태를 표시하는 UserProrile.tsx
이 요청을 재시도하고 싶지 않기 때문에 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
Load More List
Load More 버튼이 있는 무한 목록(infinite list)이 있습니다.
일반 useQuery 훅을 사용하여 구현할 수 없기 때문에,
useInfiniteQuery 훅이 있으며 fetchNextPage 함수를 사용하여 페이지네이션을 처리할 수 있습니다.
export const useGetAppointmentsList = () =>
useLoadMore<AppointmentInterface[]>(apiRoutes.getUserList);
React Query 훅에 대한 자체 추상화를 사용합니다.
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;
};
사용처
- hasNextPage, isFetchingNextPage와 같은 몇 가지 추가 필드가 있습니다.
- 다음 액션과 관련된 상태입니다.
- 그리고 메소드 fetchNextPage, fetchPreviousPage가 있습니다.
- 이전, 이후 페이지를 가져오는 메서드입니다.
Background Fetching Indicator/Refetching
- refetchInterval,
- refetchIntervalInBackground,
- refetchOnMount,
- refetchOnReconnect,
- refetchOnWindowFocus.
또한 글로벌 설정도 가능합니다. (@index.tsx)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
Making Conditional Requests
hooks를 사용한 조건부 요청은 어떻게 구현할까요?
아시다시피 훅이 있는 조건문은 사용할 수 없습니다.
예를 들어 다음과 같이 코딩할 수는 없습니다.
if (data?.hasInsurance) {
const { data: insurance } = useGetInsurance(
data?.hasInsurance ? +id : null
);
}
const { data: insurance } = useGetInsurance(data?.hasInsurance ? +id : null);
export const useGetInsurance = (id: number | null) =>
useFetch<InsuranceDetailsInterface>(
id ? pathToUrl(apiRoutes.getInsurance, { id }) : null
);
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;
};
id = 1인 appointment의 경우 hasInsurance = true입니다.
다음으로 다른 요청을 하고 이름 옆에 체크 아이콘을 표시합니다.
이는 getInsurance 엔드포인트에서 allCovered 플래그를 수신했음을 의미합니다.
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]);
},
});
};
좀 더 자세히 살펴보겠습니다. 몇 가지 콜백 메서드가 있습니다.
- 진행 중인 모든 요청을 취소합니다.
- 현재 데이터를 변수에 저장합니다.
- updater 함수를 사용하여 특정 논리로 상태를 변경하거나, 새 데이터로 상태를 재정의합니다.
- 대부분의 경우 업데이터 기능을 정의하는 것이 좋습니다.
- 이전 데이터를 반환합니다.
- 이전 데이터로 롤백합니다
- 최신 상태를 유지하기 위해 쿼리를 무효화합니다.
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을 정의합니다.
지금은 복잡한 논리를 수행하지 않고 새 상태를 반환하기만 하므로 업데이터 함수를 지정하지 않습니다.
const mutation = usePatchAppointment(+id);
export const usePatchAppointment = (id: number) =>
useUpdate<AppointmentInterface, AppointmentInterface>(
pathToUrl(apiRoutes.appointment, { id })
);
mutation.mutate([data!])
Mutation With Optimistic Changes
- 사용자는 job name을 입력하고 Add 버튼을 클릭합니다.
- 이 항목을 즉시 목록에 추가하고 Add 버튼에 로더를 표시합니다.
- 동시에 API에 요청을 보냅니다.
- 응답이 수신되면 로더를 숨기고
- 성공하면 이전 항목을 유지하고 목록에서 ID를 업데이트하고 입력 필드를 지웁니다.
- 응답이 실패하면 오류 알림을 표시하고 목록에서 이 항목을 제거하고 입력 필드를 이전 값으로 유지합니다.
- 두 경우 모두 실제 상태가 있는지 확인하기 위해 API에 GET 요청을 보냅니다.
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`);
}
};
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초 지연에도 불구하고).
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 훅을 사용합니다.
Suspense
새로운 훌륭한 개발자 경험을 얻으려면 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>
const { data } = useGetServices();
export const useGetServices = () =>
useFetch<ServiceInterface[]>(apiRoutes.getServices, undefined, {
suspense: true,
retry: 0,
});
mock.onGet(apiRoutes.getServices).reply((config) => {
if (!getUser(config)) {
return [403];
}
const failed = !!Math.round(Math.random());
if (failed) {
return [500];
}
return [200, services];
});
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),
};
};
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();
});
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();
});
expect(
mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
).toBeTruthy();
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();
});
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();
});
여기서 무슨 일이 일어나고 있는지 봅시다.
- POST 및 DELETE에 대한 요청을 모킹합니다.
- 필드에 텍스트를 입력하고 버튼을 누릅니다.
- POST 요청이 이루어지고, 실제 서버가 업데이트된 데이터를 보내야 한다고 가정(성공을 가정)하기 때문에 GET 끝점을 다시 모킹합니다.
- 이 경우 1개의 항목이 있는 리스트입니다.
- 렌더링된 컴포넌트에서 업데이트된 텍스트를 기다립니다.
- api/job에 대한 POST 요청이 호출되었는지 확인합니다.
- 삭제 버튼을 클릭합니다.
- 빈 리스트 데이터로 GET 끝점을 다시 모킹합니다(이전의 경우 서버에서 삭제 후 업데이트된 데이터를 보낸 것으로 가정).
- 삭제된 항목이 문서에 존재하지 않는지 확인하세요.
- api/job/1에 대한 DELETE 요청이 호출되었는지 확인합니다.
afterEach(() => {
jest.clearAllMocks();
});
Conclusion
- 데이터 가져오기,
- 상태 관리,
- 컴포넌트 간 데이터 공유,
- 낙관적 변경 및 무한 목록을 더 쉽게 구현하는 방법,
- 테스트로 안정적인 앱을 만드는 방법을 배웠습니다.
해당 글의 설명이 현재 또는 향후 프로젝트에서 해당 접근 방식을 시도하는 데 확신을 주었길 바랍니다.
RESOURCES
'FrontEnd' 카테고리의 다른 글
Styled-component와 Tailwind 함께 사용하기 with Twin Macro (0) | 2022.06.14 |
---|---|
React Query에는 Debounce와 Throttling이 없다? (0) | 2022.06.13 |
리액트 라우터 v6(React Router v6) 딥 다이브 (0) | 2022.06.09 |
프론트엔드 지식 : Javascript Critical Rendering Path (0) | 2022.06.08 |
리액트 성능 최적화 : Death By a Thousand Cuts (천 번 베이면 죽는다.) (0) | 2022.06.05 |