MYCHELIN 프로젝트에서 가게 상세 페이지를 맡아 진행하면서 해당 가게에 대한 댓글과 저장 데이터를 서버에서 받아와 렌더링하고, 추가, 삭제 등의 mutation을 해야 했다. 이때 React Query를 이용해 받아온 데이터를 캐싱해 사용했다.
유저가 해당 가게를 저장하거나 저장을 취소하고, 댓글을 남기거나 삭제했을 때 즉각적인 피드백을 하기 위해 useMutation, setQueryData, 그리고 invalidateQueries를 통한 낙관적 업데이트를 했다.
TL;DR
- useQuery로 고유한 쿼리 키를 사용해 데이터를 캐싱한다.
- useMutation으로 서버 데이터를 업데이트한다.
- mutation 이후에 화면을 바로 업데이트할 필요가 없다면 useMutation에 mutationFn을 전달해 서버 데이터를 업데이트한다. 이때는 서버만 업데이트하므로 쿼리 키를 알 필요가 없다. → 이후에 staleTime이 지나 캐시가 무효화되면 refetch.
- mutation 이후에 화면을 바로 업데이트해야 한다면 useMutation에 invalidateQueries 또는 setQueryData를 사용해 캐시를 업데이트한다. 이때는 useQuery에 전달했던 쿼리 키를 기반으로 캐시를 업데이트해야 하므로 쿼리 키를 알고 있어야 한다. → invalidateQueries는 바로 쿼리 무효화시키고 refetch, setQueryData는 쿼리 캐시 직접 업데이트.
- onMutate에서는 서버에 mutate 된 데이터를 보내기 전에 실행할 내용을 작성한다.(낙관적 업데이트)
- onError에서는 롤백 처리를, onSuccess 또는 onSettled에서는 쿼리 업데이트를 한다.
useMutation을 반환하는 custom hook 만들기
우선 댓글과 저장 mutations 모두에 사용할 수 있도록 useDataMutation 커스텀 훅을 만들었다. 이 훅은 mutationFn, onMutate, 그리고 queryKey를 매개변수로 받는다. 그리고 이 값들로 useMutation을 실행한 결과를 반환한다. 뭔가 복잡해 보이지만 일반적인 낙관적 업데이트 코드에서 인수만 전달받은 형태이다.
import { useQueryClient, useMutation } from '@tanstack/react-query';
// custom hook
const useDataMutation = ({
mutationFn,
onMutate: expected,
queryKey,
}) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onMutate(variable) {
// 낙관적 업데이트를 덮어쓰지 않도록 refetch 모두 취소
queryClient.cancelQueries();
// 롤백 위한 이전 상태 저장
const previousData = queryClient.getQueryData(queryKey);
// 낙관적 업데이트
queryClient.setQueryData(queryKey, expected(variable));
// 반환값은 롤백 위한 context로 사용
return previousData;
},
onError(error, variables, context) {
// 서버 요청 실패 시 롤백
queryClient.setQueryData(queryKey, context);
},
onSuccess(data, variables, context) {
// 관련 업데이트가 되는 동안 loading 상태 유지
return queryClient.invalidateQueries(queryKey);
},
});
};
export default useDataMutation;
예를 들어, 댓글을 추가하는 경우,
{
mutationFn: *newComment* => axios.post(url, *newComment*)
onMutate: newComment => comments => {
if (comments) return { ...comments, data: [newComment, ...comments.data] };
}
queryKey: [’comment’, *storeId*, *currentPage*]
}
와 같이 매개변수를 전달하고
const { mutate: addComment } = useDataMutation(해당 매개변수)
const newComment = { content: '맛있어요!', storeId: 1234567 }
addComment(newComment)
위와 같이 호출해 사용할 수 있다.
useMutation에 인수 전달하기
useMutation에 전달해야 할 수 있는 인수를 하나씩 살펴보자.
형태는
- useMutation(mutationFn, { onMutate: () ⇒ {}, onSuccess: () ⇒ {}, onError: () ⇒ {} })
- useMutation({mutationFn: () ⇒ {} ,onMutate: () ⇒ {}, onSuccess: () ⇒ {}, onError: () ⇒ {} })
처럼 mutationFn과 option 객체를 전달하거나 option 객체만 전달해 사용할 수 있다.
- mutationFn : (variables: TVariables) ⇒ Promise<TData>
우선 mutationFn은 variables를 받아 Promise를 반환하는 비동기 함수이다. 이때 variables는 mutate에 넘겨준 인수이다. 즉 위의 예시와 같다면 newComment를 variables로 받아 axios.post를 한다.
- onMutate : (variables: TVariables) ⇒ Promise<TContext | void> | TContext | void
다음으로 가장 헷갈리지만 낙관적 업데이트의 핵심인 onMutate!
useMutation의 onMutate는 mutationFn 실행 이전에 실행되고, mutationFn과 마찬가지로 mutate에 전달된 인수(ex. newComment)를 variables로 받는다. 서버에 데이터를 보내기 이전에 실행되므로 먼저 서버와의 통신이 성공했다는 가정 하에 UI를 업데이트하는 낙관적 업데이트를 하기에 유용하다.
onMutate는 variables를 받아 Promise<TContext | void> | TContext | void를 반환한다. TContext는 onMutate가 반환하는 값으로, 이후에 서버와의 통신이 실패할 경우 롤백을 위한 값(ex. previousData)이다.
- onError : (err: TError, variables: TVariables, context?: TContext) => Promise<unknown> | unknown
mutation 시 에러가 발생하면 에러를 전달받고, 실행되는 코드이다. 낙관적 업데이트 시 onMutate에서 반환하는 context 데이터를 이용해 캐시를 이전으로 되돌릴 수 있다.
- onSuccess : (data: TData, variables: TVariables, context?: TContext) => Promise<unknown> | unknown
mutation이 성공하면 mutation의 결과를 전달받아 실행되는 코드이다. 성공한 경우 쿼리를 무효화하고, 서버에 업데이트된 데이터를 다시 받아와 refetch 하도록 했다.
useDataMutation 훅에 인수 전달하기
- mutationFn
useMutation의 mutationFn에 전달할 함수
- onMutate(expected)
onMutate는 variables를 받아 setQueryData에서 실행할 Updater 함수를 반환한다. 여기서 중요한 점은 setQueryData의 두 번째 인수인 Updater함수가 (쿼리에 캐싱된 oldData) ⇒ { return 업데이트할 newData }의 형태로, 인수와 반환값이 같은 타입의 데이터를 반환해야 한다는 것이다.
- queryKey
마지막으로 queryKey는 useMutation에 직접적으로 전달하는 값은 아니지만, onMutate 내부에서 getQueryData와 setQueryData로 직접 쿼리 캐시를 업데이트할 때 사용하였다. 이것이 잘 작동하려면 useQuery를 사용해 서버에서 데이터를 받아온 데이터를 쿼리에 저장해야 하고 해당 쿼리를 mutate 해야 한다. 해당 쿼리 키를 통해 getQueryData로 캐시에 접근하고, setQueryData로 캐시를 업데이트하는 것이기 때문이다.
헷갈렸던 내용들
1. mutate에는 variables에 하나의 인수만 전달할 수 있다. 따라서 여러 개를 전달하고 싶을 때는 객체를 사용해야 한다.
const newComment = { content: '맛있어요!', storeId: 1234567 }
mutate(newComment)
2. invalidateQueries 반환하기
onSuccess(data, variables, context) {
// 관련 업데이트가 되는 동안 loading 상태 유지
return queryClient.invalidateQueries(queryKey);
},
invalidateQueries는 Promise를 반환한다. 관련된 쿼리가 업데이트되는 동안 loading 상태를 유지하려면 invalidateQueries를 반환해야 한다. 즉, 쿼리 업데이트가 끝나고 나서 다음 동작을 수행하기 위해서는 반환이 필요하다.
3. useMutation, mutate 모두 onSuccess, onError와 같은 콜백함수를 받을 수 있다. useMutation의 콜백 함수가 모두 실행되고 그다음에 mutate의 콜백함수가 실행된다. 이 순서를 잘 기억하자!
따라서, 쿼리 무효화처럼 꼭 필요한 콜백함수는 useMutation에, 페이지 전환이나(redirection) 토스트 알림을 띄우는 UI와 관련된 것들은 mutate 함수의 콜백으로 작성하면 된다고 한다.
custom hook 활용 예시
useMutation을 이용한 또 다른 예시인 저장 추가/삭제 mutation이다.
// StoreDetail.jsx
// archiveData와 storeData를 useQueries로 요청
const [{ data: archivesData }, { data: storeData }] = useQueries({
queries: [
{ queryKey: [...archiveQueryKey, storeId], queryFn: fetchArchives(storeId!) },
{ queryKey: [...storeQueryKey, storeId], queryFn: fetchStore(storeId!) },
],
});
// useArchivesMutation.js
const useArchivesMutation = (storeId) => {
const { mutate: addArchive } = useDataMutation({
mutationFn: newArchive => axios.post(`${archiveURL}`, newArchive),
// eslint-disable-next-line consistent-return
onMutate: newArchive => archives => {
if (archives) return { ...archives, archivesData: [...archives.archivesData, newArchive] };
},
queryKey: [...archiveQueryKey, storeId],
});
const { mutate: deleteArchive } = useDataMutation({
mutationFn: archiveToDelete => axios.delete(`${archiveURL}`, { data: archiveToDelete }),
onMutate: archiveToDelete => archives => {
if (archives)
// eslint-disable-next-line no-unsafe-optional-chaining
return {
...archives,
archiveData: archives?.archivesData.filter(
archive => archive.email !== archiveToDelete.email && archive.storeId !== archiveToDelete.storeId
),
};
},
queryKey: [...archiveQueryKey, storeId],
});
return { addArchive, deleteArchive };
};
REF
https://tanstack.com/query/v4/docs/react/reference/useMutation
'FrontEnd > React' 카테고리의 다른 글
TanStack Query를 활용하여 Route 기반 Prefetching하기(feat.Render-as-you-fetch) (25) | 2024.02.27 |
---|---|
[React Query] React Query에서 ErrorBoundary로 에러 핸들링하기 (3) | 2023.12.10 |
[React-Query] React Query로 페이지네이션하기(keepPreviousData 옵션) (0) | 2023.07.04 |
[React-Query] 쿼리 키 정복하기 (3) | 2023.07.03 |
[React-Query] useMutation 개념편 (0) | 2023.07.01 |