무엇
useMutation은 서버 상태를 바꾸는 요청을 다루는 훅이다. 읽기(useQuery)가 자동으로 도는 것과 달리, 뮤테이션은 명령형이다 — 함수를 트리거해야 실행된다.
인자는 객체 하나이고, mutationFn이 실제 요청을 수행하는 비동기 함수다.
const { mutate, status } = useMutation({
mutationFn: (newTodo: Todo) => postTodo(newTodo),
})
반환된 mutate(또는 mutateAsync)를 호출하는 순간 mutationFn이 돌고, 넘긴 인자가 그대로 mutationFn의 변수로 전달된다.
무슨 일이 일어나나
status는 네 가지다. idle(아직 실행 전) · pending(실행 중) · success(성공) · error(실패). useQuery와 달리 초기 상태가 idle인데, 뮤테이션은 사용자가 부를 때까지 아무 일도 안 하기 때문이다. 편의 불리언 isIdle·isPending·isSuccess·isError가 여기서 파생된다.
라이프사이클 콜백은 정해진 순서로 돈다.
onMutate:mutationFn이 돌기 전에 실행된다. 낙관적 업데이트(optimistic update)를 여기서 건다. 여기서 반환한 값은onError·onSettled의 context로 넘어간다.onSuccess또는onError: 성공/실패에 따라 하나가 실행된다.onSettled: 성공이든 실패든 마지막에 실행된다.
콜백이 프로미스를 반환하면 다음 단계로 넘어가기 전에 그 프로미스를 기다린다. 그래서 onSuccess에서 쿼리 무효화를 await하면 화면이 갱신될 때까지 pending을 유지할 수 있다.
기본값 몇 가지가 useQuery와 다르다. retry는 0(뮤테이션은 기본적으로 재시도 안 함)이고, mutate로 같은 뮤테이션을 여러 번 부르면 마지막 호출의 결과에 대해서만 onSuccess가 도는 식으로 최신 호출이 우선한다.
사용법
mutate는 fire-and-forget이고, mutateAsync는 프로미스를 돌려준다. 대부분은 mutate로 충분하고 에러는 onError나 error 상태로 다룬다. 여러 뮤테이션을 순서대로 엮거나 호출부에서 직접 try/catch 하고 싶을 때만 mutateAsync를 쓴다.
import { useMutation } from '@tanstack/react-query'
export function AddTodo() {
const { mutate, isPending, isError, error } = useMutation({
mutationFn: (title: string) => postTodo({ title }),
})
return (
<div>
<button onClick={() => mutate('새 할 일')} disabled={isPending}>
{isPending ? '추가 중' : '추가'}
</button>
{isError && <p>{error.message}</p>}
</div>
)
}
서버를 바꿨으면 관련 쿼리를 무효화해 화면을 최신으로 맞춘다. 이것이 뮤테이션과 쿼리를 잇는 가장 흔한 다리다.
import { useMutation, useQueryClient } from '@tanstack/react-query'
export function AddTodo() {
const queryClient = useQueryClient()
const { mutate } = useMutation({
mutationFn: (title: string) => postTodo({ title }),
onSuccess: () => {
// ['todos'] 쿼리를 stale 처리 → 다시 가져온다
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return <button onClick={() => mutate('새 할 일')}>추가</button>
}
실무 예시
낙관적 업데이트다. onMutate에서 화면을 먼저 바꾸고, 실패하면 onError에서 되돌린다. onMutate가 돌려준 스냅샷을 context로 받아 롤백에 쓴다.
export function useToggleTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: toggleTodo,
onMutate: async (id: string) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const prev = queryClient.getQueryData<Todo[]>(['todos'])
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
)
return { prev } // context로 넘어간다
},
onError: (_err, _id, context) => {
// 실패 시 스냅샷으로 되돌린다
if (context?.prev) queryClient.setQueryData(['todos'], context.prev)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
왜 중요한가
읽기와 쓰기는 성질이 다르다. 쓰기는 부수효과가 있고, 되돌리기·재시도·낙관적 반영을 신경 써야 하며, 성공 뒤 캐시를 어떻게 맞출지가 항상 따라붙는다. useMutation은 이 흐름(트리거 → 상태 → 라이프사이클 콜백 → 무효화)을 표준화해서, 폼 제출과 CRUD의 상태 관리를 손으로 짜지 않게 한다. useQuery와 짝을 이루는 절반이라 중요도가 같다.
Reference