Mutation
mutation에서도 query에서 했던 것처럼 전역 페칭 인디케이터 및 오류 처리를 할 수 있다.
오류의 경우, 기본적으로 queryClient의 mutations property에 onError 콜백을 설정한다. 로딩 인디케이터는 변이 호출 중 현재 해결되지 않은 것이 있는지 알려주는 useIsMutating 훅을 만들어 isMutating 또는 isFetching 상태에서 보여주도록 한다.
예시 코드를 보자.
export function generateQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
onError: queryErrorHandler,
staleTime: 600000, // 10 minutes
cacheTime: 900000, // default cacheTime is 5 minutes; doesn't make sense for staleTime to exceed cacheTime
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
mutations: {
onError: queryErrorHandler,
},
},
});
}
import { Spinner, Text } from '@chakra-ui/react';
import { ReactElement } from 'react';
import { useIsFetching, useIsMutating } from 'react-query';
export function Loading(): ReactElement {
const isFetching = useIsFetching();
const isMutating = useIsMutating(); // 현재 해결되지 않은 변이 함수의 개수
const display = isFetching || isMutating ? 'inherit' : 'none';
return (
<Spinner
thickness="4px"
speed="0.65s"
emptyColor="olive.200"
color="olive.800"
role="status"
position="fixed"
zIndex="9999"
top="50%"
left="50%"
transform="translate(-50%, -50%)"
display={display}
>
<Text display="none">Loading...</Text>
</Spinner>
);
}
useMutation과 useQuery의 차이점은 다음과 같다.
useMutation은,
- 일회성이기 때문에 캐시 데이터가 없다.
- 기본적으로 재시도를 하지 않는다. useQuery는 기본적으로 세 번 재시도한다.
- 관련된 캐시 데이터가 없으므로 refetch 하지 않는다.
- 캐시 데이터가 없으므로 isLoading(데이터가 없을 때 이루어지는 fetching)의 개념이 없다. isFetching만 존재한다.
- useMutation은 반환객체에서 mutate 함수를 반환하고 이것이 변이를 실행하는 데 사용된다.
- onMutate 콜백이 있다. 이것을 낙관적 업데이트에 사용하여 변이가 실패할 때 복원할 수 있도록 한다.
const { mutate } = useMutation({
mutationFn: void;
onSuccess: void;
})
이제 커스텀 훅으로부터 mutate를 리턴하는 것에 대한 타입을 알아보자. useMutateFunction 에는 다양한 파라미터들이 있다. 파라미터들의 type은 다음과 같다.
이제 예시 코드를 보자.
async function setAppointmentUser(
appointment: Appointment,
userId: number | undefined
): Promise<void> {
if (!userId) return;
const patchOp = appointment.userId ? 'replace' : 'add';
const patchData = [{ op: patchOp, path: '/userId', value: userId }];
await axiosInstance.patch(`/appointment/${appointment.id}`, {
data: patchData,
});
}
export function useReserveAppointment(): UseMutateFunction<
void,
unknown,
Appointment,
unknown
> {
const { user } = useUser();
const toast = useCustomToast();
const queryClient = useQueryClient();
const { mutate } = useMutation(
(appointment: Appointment) => setAppointmentUser(appointment, user?.id),
{
onSuccess: () => {
queryClient.invalidateQueries([queryKeys.appointments]);
toast({
title: 'You have reserved the appointment!',
status: 'success',
});
},
}
);
return mutate;
}
mutation 이후 서버와 동기화하는 방법
1. 쿼리 무효(Invalidating query)
쿼리를 무효화하면 캐시를 지우고 refetch 한다. queryClient에는 쿼리를 무효하는 invalidateQueries 메서드가 있다. 이를 유저가 예약 시 appointment에 대한 캐시를 무효화하는 데 사용하여 새로 고침을 하지 않아도 새로운 데이터를 보게 하는데에 사용할 수 있다.
invalidateQueries의 effect는 다음과 같다.
- 쿼리를 만료(stale) 상태로 표시한다.
- 쿼리가 렌더링 중이면 refetch를 트리거한다.
Flow를 간단히 표현하면 다음과 같다.
mutate 함수 실행-> onSuccess 핸들러가 관련 쿼리를 무효화 -> data refetch trigger -> data update
const { mutate } = useMutation(
(appointment: Appointment) => setAppointmentUser(appointment, user?.id),
{
onSuccess: () => {
queryClient.invalidateQueries([queryKeys.appointments]);
toast({
title: 'You have reserved the appointment!',
status: 'success',
});
},
}
prefix(쿼리 키 접두사)
위 코드에서는 prefix로 queryKeys.appointments를 가지는 쿼리를 무효화하여 예약한 내용이 캘린더에는 바로 적용되었지만 하단에 보이는 사용자 예약 내역은 새로운 데이터가 적용되지 않았다. 이를 해결하기 위해 예약과 관련된 모든 mutation을 무효화하는 작업이 필요하고 이를 쿼리 키 접두사로 해결할 수 있다.
invalidateQueries는 정확한 키가 아닌 접두사를 사용한다. 따라서 동일한 쿼리 키 접두사로 서로 관련된 쿼리를 설정하면 모든 쿼리를 한 번에 무효화할 수 있다. (정확한 키로 설정하고 싶다면 exact : true로 설정하면 된다.) 참고로 queryClient도 prefix를 사용한다.
예시로 만드는 앱에서의 예약, 유저 예약 정보의 쿼리 키 배열을 살펴보자.
쿼리를 무효화하기 위한 접두사로 queryKeys.appointments를 전달하면 위의 두 가지 쿼리를 모두 무효화시킬 수 있다.
queryClient.invalidateQueries([queryKeys.appointments]);
// useUserAppointments.ts
export function useUserAppointments(): Appointment[] {
const { user } = useUser();
const fallback: Appointment[] = [];
const { data: userAppointments = fallback } = useQuery(
[queryKeys.appointments, queryKeys.user, user?.id],
() => getUserAppointments(user),
{
enabled: !!user,
}
);
return userAppointments;
}
// useUser.ts
function clearUser() {
// reset user to null
queryClient.setQueryData(queryKeys.user, null);
// remove user appointments query
queryClient.removeQueries([queryKeys.appointments, queryKeys.user]);
}
다른 예시로 예약을 삭제하는 코드를 보자. invalidateQueries의 키로 queryKeys.appointments를 전달했기 때문에 예약을 삭제하면 예약 리스트에서도, 캘린더에서도 예약 정보가 최신 데이터로 업데이트된 것을 확인할 수 있다.
export function useCancelAppointment(): UseMutateFunction<
void,
unknown,
Appointment,
unknown
> {
const queryClient = useQueryClient();
const toast = useCustomToast();
const { mutate } = useMutation(removeAppointmentUser, {
onSuccess: () => {
queryClient.invalidateQueries([queryKeys.appointments]);
toast({
title: 'You have canceled the appointment!',
status: 'warning',
});
},
});
return mutate;
}
복잡한 앱의 경우 전체적인 일관성 유지를 위해 쿼리 키를 생성하는 함수를 이용한다.
2. mutation 응답으로 사용자와 쿼리 캐시 업데이트
이제 mutate 후 서버가 보낸 응답을 사용해 캐시를 업데이트하는 방법을 알아보자.
예시로, usePatchUser라는 새로운 커스텀 훅을 살펴보자. 이 훅에 onSuccess를 넣을 것이고, onSuccess는 서버로부터 응답을 받은 데이터로 캐시를 업데이트해 화면에 새로운 데이터를 보여줄 수 있도록 하자.
// for when we need a server function
async function patchUserOnServer(
newData: User | null,
originalData: User | null
): Promise<User | null> {
if (!newData || !originalData) return null;
// create a patch for the difference between newData and originalData
const patch = jsonpatch.compare(originalData, newData);
// send patched data to the server
const { data } = await axiosInstance.patch(
`/user/${originalData.id}`,
{ patch },
{
headers: getJWTHeader(originalData),
}
);
return data.user;
}
export function usePatchUser(): UseMutateFunction<
User,
unknown,
User,
unknown
> {
const { user, updateUser } = useUser();
const toast = useCustomToast();
const { mutate: patchUser } = useMutation(
(newUserData: User) => patchUserOnServer(newUserData, user),
{
// mutation 함수의 응답 데이터를 받아 유저 데이터 업데이트
onSuccess: (userData: User | null) => {
if (userData) {
updateUser(userData)
toast({
title: 'User updated!',
status: 'success',
});
}
},
);
return patchUser;
}
3. 낙관적 업데이트(optimistic update)
낙관적인 업데이트는 서버에서 응답을 받기 전에 mutation이 잘 작동할 것이라고 가정하고 캐시를 업데이트하는 것이다. 장점은 캐시가 빠르게 업데이트되어 사용자에게 훨씬 민감하게 반응한다는 것이다. 단점은 서버 업데이트가 실패한 경우 코드가 복잡해진다는 것이다. 이 경우 업데이트 이전의 데이터로 돌아가야 한다.
useMutation에는 onMutate 콜백이 있다. 이것은 콘텍스트 값을 반환하고 onError 핸들러가 이 컨텍스트 값을 아규먼트로 받아 캐시 값을 이전으로 복원할 수 있다. (이 경우 콘텍스트는 낙관적 업데이트를 적용하기 전 캐시 데이터를 말한다.)
onMutate 함수는 쿼리를 취소할 수도 있다. 만약 쿼리를 취소하지 않으면 쿼리가 다시 가져와질 수 있으며, refetch가 진행되는 동안 캐시가 업데이트되어 서버에서 다시 가져온 이전 데이터로 캐시가 덮었으일 수 있으므로 꼭 취소해야 한다.
낙관적 업데이트의 flow를 살펴보자.
- 사용자가 Mutate를 트리거한다.
- 서버에게 업데이트를 요청하며 onMutate 콜백이 실행된다.
onMutate 콜백은 다음과 같은 몇 가지 작업을 실행한다.
- 진행 중인 쿼리를 취소한다.
- 쿼리 캐시를 낙관적으로 업데이트한다.
- 이전 캐시 값을 onMutate 핸들러에서 반환된 콘텍스트로 저장한다.
만약 업데이트가 성공적이었다면 서버에서 최신 데이터를 가져올 수 있도록 캐시를 무효화하면 된다. 하지만 그렇지 않다면 onError 콜백이 실행되어 onMutate에서 반환된 콘텍스트를 사용해 캐시를 낙관적 업데이트하기 전으로 되돌린다. 그 이후 쿼리 또한 무효화하여 서버에서 가장 최신 데이터를 받을 수 있도록 한다.
쿼리 취소
낙관적인 업데이트에서 중요한 점은 서버로 요청이 전달되는 도중에 취소할 수 있다는 점으로, 서버에서 오는 모든 데이터가 캐시의 낙관적 업데이트를 덮어쓰는 일이 없도록 하는 것이다.
지금까지 우리가 useQuery에 전달한 함수는 리액트 쿼리가 취소할 수 있는 형식이 아니다. 리액트 쿼리는 특정 형식의 쿼리만 쿼리 캐시 명령어를 사용하여 취소할 수 있다. 구체적으로 취소 프로퍼티(쿼리를 취소하는 함수)를 가진 프로미스를 반환하는 쿼리 함수가 필요하다. 리액트 쿼리에 쿼리 취소를 요청하면 이 취소 함수가 실행된다.
리액트 쿼리는 AbortController를 사용해 쿼리를 취소한다. 이것은 표준 자바스크립트 인터페이스이며, 핵심은 AbortSignal 객체를 DOM 요청에 보내는 것이다. ( 어떤 쿼리는 배후에서 자동적으로 취소된다. 예를 들어 어떤 쿼리가 가동 중에 기한이 만료하거나 비활성 되는 경우 내지는 쿼리 결과를 보여주는 컴포넌트가 해체되는 경우가 있겠다. )
리액트 쿼리에서 이 방법을 사용해 axios 쿼리를 수동적으로 취소하려면 axios에 중단한다는 signal을 전달하여 쿼리 함수에 보내야 한다.
예시 코드를 살펴보자.
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에게 pass하고 이 signal을 사용해 axios 요청을 중단할 수 있다.
({ 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 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 };
}
- 사용자 쿼리 키를 지닌 useQuery가 AbortController를 관리한다.
- 이 컨트롤러는 getUser에 전달되는 신호를 생성하고 getUser는 해당 신호를 axios에게 전달하고, Axios는 그 신호에 연결된 상태가 된다. ( 취소 이벤트에 대하여 해당 신호를 수신하는 것이다. )
이제 mutate 함수에 낙관적 업데이트를 적용해 보자.
import jsonpatch from 'fast-json-patch';
import { UseMutateFunction, useMutation, useQueryClient } from 'react-query';
import type { User } from '../../../../../shared/types';
import { axiosInstance, getJWTHeader } from '../../../axiosInstance';
import { queryKeys } from '../../../react-query/constants';
import { useCustomToast } from '../../app/hooks/useCustomToast';
import { useUser } from './useUser';
// for when we need a server function
async function patchUserOnServer(
newData: User | null,
originalData: User | null
): Promise<User | null> {
if (!newData || !originalData) return null;
// create a patch for the difference between newData and originalData
const patch = jsonpatch.compare(originalData, newData);
// send patched data to the server
const { data } = await axiosInstance.patch(
`/user/${originalData.id}`,
{ patch },
{
headers: getJWTHeader(originalData),
}
);
return data.user;
}
export function usePatchUser(): UseMutateFunction<
User,
unknown,
User,
unknown
> {
const { user, updateUser } = useUser();
const toast = useCustomToast();
const queryClient = useQueryClient();
const { mutate: patchUser } = useMutation(
(newUserData: User) => patchUserOnServer(newUserData, user),
{
// onMutate returns context that is passed to onError
onMutate: async (newData: User | null) => {
// cancel any outgoing queries for user data, so old server data
// doesn't overwrite our optimistic update
queryClient.cancelQueries(queryKeys.user);
// snapshot of previous user value
const previousUserData: User = queryClient.getQueryData(queryKeys.user);
// optimistically update the cache with new user value
updateUser(newData);
// return context object with snapshotted value
return { previousUserData };
},
onError: (error, newData, context) => {
// roll back cache to saved value
if (context.previousUserData) {
updateUser(context.previousUserData);
toast({
title: 'Update failed; restoring previous values',
status: 'warning',
});
}
},
onSuccess: (userData: User | null) => {
// note: the conditional here should be `userData`, not `user` as shown
// in the video.
// see: https://www.udemy.com/course/learn-react-query/learn/#questions/18361988/
if (userData) {
toast({
title: 'User updated!',
status: 'success',
});
}
},
// mutation을 resolve 했을 때 성공 여부와 상관없이 이 콜백이 실행된다.
onSettled: () => {
// invalidate user query to make sure we're in sync with server data
queryClient.invalidateQueries(queryKeys.user);
},
}
);
return patchUser;
}
낙관적 업데이트는 동일한 데이터를 사용하는 컴포넌트가 다수 있고 서버에서 업데이트가 조금 오래 걸릴 경우 아주 강력하며 사용자 측에서는 웹사이트가 훨씬 반응성이 좋게 느껴진다.
'온라인 강의(유데미, 인프런 등) > React-Query(유데미)' 카테고리의 다른 글
React Query와 인증 & setQueryData & initialData (0) | 2023.09.04 |
---|---|
쿼리 특성 2 : select 옵션을 통한 데이터 filtering과 refetching & polling (0) | 2023.09.01 |
쿼리 특성 1 : Pre-fetching과 페이지네이션 (0) | 2023.09.01 |
커스텀 쿼리 훅(관심사 분리) & 로딩 / 에러 처리 중앙집중화 (0) | 2023.08.31 |
useInfiniteQuery (0) | 2023.08.31 |