✒️ Mutation 이란 무엇인가?
- mutation 이란, 서버에 Side-Effect 를 일으키도록 하는 함수다. 인계 받은 쿼리 값을 기반으로 새로운 결과를 도출하여 서버에 변경 사항을 적용하도록 요청하는 기능을 한다.
- 보통 POST, PUT, DELETE 같이 데이터를 수정하거나 추가하는 요청을 보낼 때 같이 사용되며, react-query 에서는 관련 작업을 위해 useMutation 훅을 지원한다.
📒 useQuery 와의 유사점과 차이점
- 일반적으로 쿼리는 자동으로 실행된다. refetchOnWindowFocus 옵션을 통해서 창에 포커스를 맞출 경우 백그라운드 단에서 refetch 를 요청하거나, refetchInterval 옵션을 사용하여 일정 기간마다 refetch 작업을 수행한다. 굳이 사용자가 refetch 작업을 지정하지 않아도 백그라운드 단에서 진행된다.
- 하지만 mutation 은 사용자가 선언적으로 언제 데이터를 업데이트 할지를 직접 지정해줘야 한다. react-query 에서는 useMutation 훅의 반환값인 mutate 함수를 호출함으로서 mutation 작업을 시행할 수 있게 한다.
function AddComment({ id }) {
const addComment = useMutation({
mutationFn: (newComment) =>
axios.post(`/posts/${id}/comments`, newComment),
})
return (
<form
onSubmit={(event) => {
event.preventDefault()
addComment.mutate(new FormData(event.currentTarget).get('comment'))
}}
>
<textarea name="comment" />
<button type="submit">Comment</button>
</form>
)
}
- 또 다른 차이점은 useQuery 는 하나의 queryCache 를 구독하는 여러 queryObserver 들을 생성하는 역할이므로 여러 컴포넌트에서 동일한 훅이 호출되더라도 같은 결과를 전달 받는다. (캐싱된 결과 반환)
- 하지만 mutation 의 경우에는 여러 곳에서 훅이 호출되고, 작업이 시행되었다면 실행된 순간마다 각기 다른 값을 반환하므로 같은 결과를 전달 받지 않는다.
- mutation 작업의 결과는 mutationCache 에 저장되고, 이는 useMutation 훅을 호출할 때마다 1:1 로 대응된다. 따라서 같은 작업을 하는 mutateFn 을 가진 훅이더라도 각기 다른 작업으로 처리될 수밖에 없다.
✒️ useMutation 사용법
- mutation 작업도 마찬가지로 Promise 를 반환하는 비동기 함수를 인자로 넣어야 하며, 작업이 성공했을 때와 실패했을 때, 그리고 작업 성공 여부와 관계 없이 항상 실행되는 콜백 함수를 넣을 수 있다.
- onSuccess, onSettled, onError 옵션을 통해 useMutation 훅의 작업 흐름을 제어할 수 있다.
import { useMutation } from "react-query";
const { data, isLoading, mutate } = useMutation(mutationFn, options);
mutate(variables, {
onError,
onSettled,
onSuccess,
});
- hook 을 호출하여 나온 결과 값 중,
mutate
객체를 통해 실질적으로 데이터를 수정하도록 요청하는 작업을 수행할 수 있으며, mutate 객체의 결과에 따라서도 콜백 함수를 추가할 수 있다.
- onSuccess, onSettled, onError 옵션을 통해 useMutation 훅의 작업 흐름을 제어할 수 있다.
- onMutate의 경우 mutation 작업 이전에 실행되는 함수이므로 optimistic update 에 쓰인다. 또한 onMutate 에서 반환된 값은 onSuccess, onSettled, onError 콜백의 context 인자에 담긴다.
- useMutation 내에 정의된 콜백 함수가 먼저 실행되고 이후 mutate 에 정의된 콜백 함수가 실행된다.
- 만약 컴포넌트가 중간에 unmount 되면 더 이상 callback 이 실행되지 않으므로 이를 유의해야 한다.
useMutation(addTodo, {
onSuccess: (data, variables, context) => {
},
onError: (error, variables, context) => {
},
onSettled: (data, error, variables, context) => {
},
});
mutate(todo, {
onSuccess: (data, variables, context) => {
},
onError: (error, variables, context) => {
},
onSettled: (data, error, variables, context) => {
},
});
variables
인자의 경우 mutate 함수가 호출되었을 때 인자로 들어간 값이 담긴다.
error
인자의 경우 mutate 함수가 에러를 일으켰을 때 반환된 결과가 담긴다.
data
인자의 경우 mutationFn 이 실행된 결과 값이 담긴다. (onSuccess, onSettled 에 쓰임)
context
의 경우 onMutate 콜백 함수가 반환한 값이 담긴다. (onError, onSettled 에 쓰임)
✒️ 결과를 업데이트 하는 방법
- 첫 번째는 mutation 이 성공적으로 수행되었을 때 실행되는 onSuccess 콜백 함수 내에서 특정 쿼리를 invalid 처리 함으로서 백그라운드 단에서 자동으로 refetch 되게끔 하는 기법이 있다. (Query Invalidation)
- 두 번째는 mutation 작업의 결과 값을 인계 받아 onSuccess 콜백 함수 내에서 특정 쿼리에 내장된 데이터를 queryClient.setQueryData 메서드로 업데이트 하는 방식이 있다. (**Updates from Mutation Responses**)
- 세 번째는 mutation 성공 여부와 관계 없이, 일단 해당 작업이 성공적으로 수행되었으리라 기대하고 클라이언트 단에서 mutation 작업 수행 전에 쿼리의 데이터를 먼저 변경해버리는 방식이 있다. (**Optimistic Updates)**
📒 Query Invalidation
QueryClient.invalidateQueries
메서드를 시용하면 특정 쿼리의 status를 즉시 stale로 변경하며 백그라운드 단에서 refetch가 일어나도록 유도한다.
- 해당 메서드의 경우 각 쿼리 별로 지정된 staleTime 설정을 모두 무시하며, useQuery 혹은 다른 hook 에 의해 렌더링 된 상태이더라도 백그라운드 단에서 refetch 작업이 이루어진다.
- Query Filter 를 사용하여 배열 형태의 query key 중 특정 범주의 쿼리 상태만을 stale 하게 만들 수도 있다. 혹은 exact, predict 옵션을 사용하여 특정 조건에 부합하는 쿼리만 stale 하게 처리할 수도 있다.
- https://tanstack.com/query/v4/docs/react/guides/filters#query-filters : Query Filter 사용법
import { useQuery, useQueryClient } from '@tanstack/react-query'
const queryClient = useQueryClient()
const todoListQuery = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})
const todoListQuery = useQuery({
queryKey: ['todos', { page: 1 }],
queryFn: fetchTodoList,
})
queryClient.invalidateQueries({ queryKey: ['todos'] })
queryClient.invalidateQueries({ queryKey: ['todos', { page: 1 }] })
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })
queryClient.invalidateQueries({ queryKey: ['todos'], type: 'inactive' })
queryClient.invalidateQueries({ queryKey: ['todos'], stale: false })
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' && query.queryKey[1]?.page >= 1,
})
- 이를 mutation 작업과 연관지으면, mutate 작업이 성공적으로 수행되었을 때 업데이트 해야 하는 특정 쿼리를 invalid 하게 만들어 refetch 를 유도할 수 있다.
import { useMutation, useQueryClient } from '@tanstack/react-query'
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
📒 Updates from Mutation Responses
- mutation 작업의 결과를 인계 받은 후, 이를 직접 쿼리에 적용할 수도 있다.
- invalidation 방식의 경우 쿼리의 상태를 stale 하게 만들기 때문에 이후 refetch 작업이 필연적으로 발생하는데, 굳이 불필요하게 API 를 한번 더 요청하고 싶지 않다면 이 방식을 쓰자.
- 즉, mutateFn 의 반환 값이 존재하고, 이것이 유효한 값임을 보장할 때는 굳이 refetch 를 유도하지 않고 서버로부터 반환된 결과 값을 즉시 업데이트 하는 것이 더욱 바람직하다.
const useMutateTodo = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: editTodo,
onSuccess: (data, variables) => {
queryClient.setQueryData(['todo', { id: variables.id }], data)
},
})
}
QueryClient.setQueryData
메서드를 시용하여 특정 쿼리에 저장된 캐싱 데이터를 수동으로 변경해준다.
- 단 캐싱된 데이터가 참조형일 경우에는 불변성을 지켜서 새로운 객체, 혹은 배열로 수정된 값을 넣어줘야 한다. 그렇지 않을 경우 동작은 잘 되지만 내부적으로 버그가 발생할 수 있다고 한다. (공식 문서 참조)
💡 Updates via **`setQueryData`** must be performed in an *immutable* way. **DO NOT** attempt to write directly to the cache by mutating data (that you retrieved via from the cache) in place. It might work at first but can lead to subtle bugs along the way.
ueryClient.setQueryData(
['posts', { id }],
(oldData) => {
if (oldData) {
oldData.title = 'my new post title'
}
return oldData
})
queryClient.setQueryData(
['posts', { id }],
(oldData) => oldData ? {
...oldData,
title: 'my new post title'
} : oldData
)
📒 Optimistic Update
- mutation 작업이 시행되기 이전에 해당 작업이 이미 성공했다고 가정하고, 미리 쿼리에 캐싱된 값을 예상된 결과로 즉시 업데이트 하는 기법이다.
- 해당 API 가 무조건 성공한다는 것을 전제로 수행하기 때문에 만약 실패했을 경우 mutation 작업이 진행되기 전의 값으로 쿼리 데이터를 되돌린다.
- 값이 변경되었다가 다시 되돌아가는 과정은 UX 적으로 매우 좋지 않기 때문에 거의 확실하게 요청이 성공할 것 같은 케이스에 대해서만 적용하자.
const queryClient = useQueryClient()
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
return { previousTodos }
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})