React Query를 사용하면서 삭제 요청으로 mutationFn
이 실행된 후 서버와 클라이언트의 상태가 일치하지 않는 문제가 있었다.
처음에는 캐시된 데이터의 문제라고 생각하여 간단하게 useMutation
hook 안의 onSuccess
에서 invalidateQueries
를 호출해 캐시를 삭제하는 방법을 사용했다.
export const useDeleteCenter = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: centerAPI.deleteCenter,
onSuccess: () => {
queryClient.invalidateQueries(["getCenters"]);
},
});
};
invalidateQueries
는 쿼리를 무효화해서 서버에서 데이터를 다시 가져오는 메서드다.
하지만 실제 동작에서는 캐시를 삭제해서 데이터를 불러오는 것과 삭제 요청의 순서가 일정하지 않은 문제가 발생했다.
내가 원하는 동작은 아래와 같다.
클라이언트에서 서버로 삭제 요청
서버에서 DB의 데이터 변경
서버에서 요청에 대한 처리 후 클라이언트로 응답
클라이언트에서는 응답을 받은 후 기존의 쿼리를 무효화해 서버로 다시 조회 요청
서버로부터 변경 후의 새로운 데이터를 받아서 렌더링
하지만 실제 동작에서는 순서가 달라지는 경우가 있었다.
2번에서의 데이터 변경에 딜레이가 생겨서 4번에서 받은 조회 요청이 변경 전의 데이터가 조회되는 경우였다.
서버에서의 처리가 비동기적으로 동작하기 때문에 클라이언트에서 동기적으로 호출해도 정상적으로 처리가 되지 않는 것이다.
invalidateQueries
에서 옵션으로 딜레이를 설정해 서버에 요청을 늦게 보내는 방법도 있지만, 올바른 방법이 아니라고 생각되어 다른 방법을 찾아보게 되었다.
사전에서 낙관적이라는 말은 인생이나 사물을 긍정적인 마인드로 보는 것으로 정의된다.
이처럼 낙관적인 업데이트는 서버로 보낸 요청이 정상적일 것이라고 예상하고, 클라이언트의 요청에 대한 응답이 오기 전에 클라이언트의 데이터를 미리 변경시키는 작업을 말한다.
이 방법은 useMutation
hook에서 onSuccess
가 아니라 onMutate
메서드와 onError
메서드를 조합하여 구현할 수 있었다.
아래의 코드는 리스트로 나열된 콘텐츠 중 하나의 index
를 받아서 해당 index
의 콘텐츠를 삭제하는 custom hook이다.
export const useDeleteCenter = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: centerAPI.deleteCenter,
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey: ["getCenters"] }); // 쿼리 요청 취소
const oldData = queryClient.getQueryData(["getCenters"]); // 현재 캐시된 데이터 가져오기
// 새로운 데이터로 가공
const newData = { ...oldData }; // 객체 복사
newData.data = [...oldData.data].filter(
// 내부 데이터 복사
(item) => item.id !== variables
);
queryClient.setQueryData(["getCenters"], {
...newData, // 새로운 데이터로 캐시에 저장
});
return { oldData }; // 다음 context로 넘기기 위해 반환
},
onError: (err, context) => {
// 에러 발생시 이전 데이터로 캐시 저장
queryClient.setQueryData(["getCenters"], { ...context.oldData });
},
});
};
onMutate
콜백은 mutation이 시작될 때 호출되는 함수로, mutation이 실행되기 전에 동기적으로 실행된다.
variables
는 mutation 함수에 전달되는 변수다.
async
와 await
를 사용해 cancelQueries
를 호출해서 onMutate
의 동작 이후 Error가 발생했을 때 refetch가 일어나지 않도록 방지한다.
이후 getQueryData
메서드를 통해 현재 캐시된 데이터를 가져와서 저장하는데 이는 서버에서 에러를 반환했을 경우 기존 데이터로 되돌리기 위해서다.
이제 업데이트된 상태로 데이터를 가공하여 캐시에 저장하는데 여기서 주의할 점이 있다.
캐시에 저장하는 데이터는
useState
처럼 불변성을 지켜야 한다. 불변성을 지키지 않고 캐시에 데이터를 저장한다면 React에서는 이를 감지하지 못하고 리렌더링하지 않는다.
기존의 저장된 데이터(oldData
)를 반환하면 useMutation
hook에서 다음 context로 받아서 사용할 수 있다.
onError
콜백은 query에서 에러가 발생하면 호출되는데 이전에 반환했던 oldData
를 받아서 에러가 발생했을 때 기존의 데이터로 캐시를 되돌리는 기능을 한다.
이렇게 기본만 잘 지킨다면 간단하게 구현할 수 있다.
공식문서에서 Best Case를 보여주는데, onSettled
콜백에서 쿼리를 무효화하고 동기화하는 동작이 있는 것을 확인할 수 있다.
본인은 왜 추가하지 않았냐면 낙관적인 업데이트를 했는데, 쿼리를 무효화하고 요청한다면 이전 처럼 서버에서 변경 전의 데이터를 가져와 덮어씌워질 수 있기 때문이다. (주관적인 생각이기 때문에 아닐 수 있다.)
참고
[TanStack Query v5] Optimistic Updates
React Query에서 mutation 이후 데이터를 업데이트하는 4가지 방법