본문으로 건너뛰기

뮤테이션

쿼리와 달리, 뮤테이션은 일반적으로 데이터를 생성/업데이트/삭제하거나 서버 부수 효과를 수행하는 데 사용합니다. 이를 위해 TanStack Query는 useMutation 훅을 내보냅니다.

다음은 서버에 새 할 일을 추가하는 뮤테이션의 예시입니다:

function App() {
const mutation = useMutation({
mutationFn: (newTodo) => {
return axios.post('/todos', newTodo)
},
})

return (
<div>
{mutation.isPending ? (
'Adding todo...'
) : (
<>
{mutation.isError ? (
<div>An error occurred: {mutation.error.message}</div>
) : null}

{mutation.isSuccess ? <div>Todo added!</div> : null}

<button
onClick={() => {
mutation.mutate({ id: new Date(), title: 'Do Laundry' })
}}
>
Create Todo
</button>
</>
)}
</div>
)
}

뮤테이션은 특정 시점에 다음 상태 중 하나에만 있을 수 있습니다:

  • isIdle 또는 status === 'idle' - 뮤테이션이 현재 유휴 상태이거나 새로운/재설정된 상태입니다
  • isPending 또는 status === 'pending' - 뮤테이션이 현재 실행 중입니다
  • isError 또는 status === 'error' - 뮤테이션에서 오류가 발생했습니다
  • isSuccess 또는 status === 'success' - 뮤테이션이 성공했으며 뮤테이션 데이터를 사용할 수 있습니다

이러한 주요 상태 외에도 뮤테이션의 상태에 따라 더 많은 정보를 사용할 수 있습니다:

  • error - 뮤테이션이 error 상태이면 error 속성을 통해 오류를 확인할 수 있습니다.
  • data - 뮤테이션이 success 상태이면 data 속성을 통해 데이터를 사용할 수 있습니다.

위 예제에서는 mutate 함수를 단일 변수 또는 객체와 함께 호출하여 뮤테이션 함수에 변수를 전달할 수 있다는 것도 확인했습니다.

변수만 사용해도 뮤테이션이 특별한 것은 아니지만, onSuccess 옵션, Query Client의 invalidateQueries 메서드Query Client의 setQueryData 메서드와 함께 사용하면 뮤테이션은 매우 강력한 도구가 됩니다.

중요: mutate 함수는 비동기 함수이므로 React 16 이하에서는 이벤트 콜백에서 직접 사용할 수 없습니다. onSubmit에서 이벤트에 접근해야 한다면 mutate를 다른 함수로 감싸야 합니다. 이는 React 이벤트 풀링 때문입니다.

// This will not work in React 16 and earlier
const CreateTodo = () => {
const mutation = useMutation({
mutationFn: (event) => {
event.preventDefault()
return fetch('/api', new FormData(event.target))
},
})

return <form onSubmit={mutation.mutate}>...</form>
}

// This will work
const CreateTodo = () => {
const mutation = useMutation({
mutationFn: (formData) => {
return fetch('/api', formData)
},
})
const onSubmit = (event) => {
event.preventDefault()
mutation.mutate(new FormData(event.target))
}

return <form onSubmit={onSubmit}>...</form>
}

뮤테이션 상태 재설정

때로는 뮤테이션 요청의 error 또는 data를 지워야 할 수 있습니다. 이를 위해 reset 함수를 사용하여 처리할 수 있습니다:

const CreateTodo = () => {
const [title, setTitle] = useState('')
const mutation = useMutation({ mutationFn: createTodo })

const onCreateTodo = (e) => {
e.preventDefault()
mutation.mutate({ title })
}

return (
<form onSubmit={onCreateTodo}>
{mutation.error && (
<h5 onClick={() => mutation.reset()}>{mutation.error}</h5>
)}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<br />
<button type="submit">Create Todo</button>
</form>
)
}

뮤테이션 부수 효과

useMutation에는 뮤테이션 수명 주기의 어느 단계에서든 부수 효과를 빠르고 쉽게 실행할 수 있는 몇 가지 도우미 옵션이 제공됩니다. 이는 뮤테이션 후 쿼리를 무효화하고 다시 가져오는 작업뿐 아니라 낙관적 업데이트에도 유용합니다.

useMutation({
mutationFn: addTodo,
onMutate: (variables, context) => {
// A mutation is about to happen!

// Optionally return a result containing data to use when for example rolling back
return { id: 1 }
},
onError: (error, variables, onMutateResult, context) => {
// An error happened!
console.log(`rolling back optimistic update with id ${onMutateResult.id}`)
},
onSuccess: (data, variables, onMutateResult, context) => {
// Boom baby!
},
onSettled: (data, error, variables, onMutateResult, context) => {
// Error or success... doesn't matter!
},
})

콜백 함수 중 하나에서 Promise를 반환하면 다음 콜백이 호출되기 전에 먼저 해당 Promise가 완료될 때까지 기다립니다:

useMutation({
mutationFn: addTodo,
onSuccess: async () => {
console.log("I'm first!")
},
onSettled: async () => {
console.log("I'm second!")
},
})

mutate를 호출할 때 useMutation에 정의된 콜백 외에 추가 콜백을 실행하고 싶을 수 있습니다. 이를 사용해 컴포넌트별 부수 효과를 실행할 수 있습니다. 이렇게 하려면 뮤테이션 변수 뒤에 있는 mutate 함수에 동일한 콜백 옵션을 제공할 수 있습니다. 지원되는 옵션은 onSuccess, onErroronSettled입니다. 뮤테이션이 완료되기 전에 컴포넌트가 마운트 해제되면 이러한 추가 콜백이 실행되지 않는다는 점에 유의하세요.

useMutation({
mutationFn: addTodo,
onSuccess: (data, variables, onMutateResult, context) => {
// I will fire first
},
onError: (error, variables, onMutateResult, context) => {
// I will fire first
},
onSettled: (data, error, variables, onMutateResult, context) => {
// I will fire first
},
})

mutate(todo, {
onSuccess: (data, variables, onMutateResult, context) => {
// I will fire second!
},
onError: (error, variables, onMutateResult, context) => {
// I will fire second!
},
onSettled: (data, error, variables, onMutateResult, context) => {
// I will fire second!
},
})

연속 뮤테이션

연속된 뮤테이션의 경우 onSuccess, onErroronSettled 콜백을 처리하는 방식에 약간의 차이가 있습니다. mutate 함수에 전달하면 해당 콜백은 한 번만, 그리고 컴포넌트가 여전히 마운트된 경우에만 실행됩니다. 이는 mutate 함수가 호출될 때마다 뮤테이션 옵저버가 제거된 후 다시 구독되기 때문입니다. 반면 useMutation 핸들러는 각 mutate 호출마다 실행됩니다.

useMutation에 전달된 mutationFn은 비동기일 가능성이 매우 높다는 점에 유의하세요. 이 경우 뮤테이션이 이행되는 순서는 mutate 함수 호출 순서와 다를 수 있습니다.

useMutation({
mutationFn: addTodo,
onSuccess: (data, variables, onMutateResult, context) => {
// Will be called 3 times
},
})

const todos = ['Todo 1', 'Todo 2', 'Todo 3']
todos.forEach((todo) => {
mutate(todo, {
onSuccess: (data, variables, onMutateResult, context) => {
// Will execute only once, for the last mutation (Todo 3),
// regardless which mutation resolves first
},
})
})

Promise

성공 시 이행되거나 오류 발생 시 오류를 발생시키는 Promise를 얻으려면 mutate 대신 mutateAsync를 사용합니다. 예를 들어 이를 사용하여 부수 효과를 조합할 수 있습니다.

const mutation = useMutation({ mutationFn: addTodo })

try {
const todo = await mutation.mutateAsync(todo)
console.log(todo)
} catch (error) {
console.error(error)
} finally {
console.log('done')
}

재시도

기본적으로 TanStack Query는 오류 발생 시 뮤테이션을 재시도하지 않지만, retry 옵션을 사용하면 재시도할 수 있습니다:

const mutation = useMutation({
mutationFn: addTodo,
retry: 3,
})

기기가 오프라인이어서 뮤테이션이 실패하면 기기가 다시 연결될 때 동일한 순서로 재시도됩니다.

뮤테이션 영구 저장

필요한 경우 뮤테이션을 저장소에 영구 저장하고 나중에 재개할 수 있습니다. 하이드레이션 함수로 이를 수행할 수 있습니다:

const queryClient = new QueryClient()

// Define the "addTodo" mutation
queryClient.setMutationDefaults(['addTodo'], {
mutationFn: addTodo,
onMutate: async (variables, context) => {
// Cancel current queries for the todos list
await context.client.cancelQueries({ queryKey: ['todos'] })

// Create optimistic todo
const optimisticTodo = { id: uuid(), title: variables.title }

// Add optimistic todo to todos list
context.client.setQueryData(['todos'], (old) => [...old, optimisticTodo])

// Return a result with the optimistic todo
return { optimisticTodo }
},
onSuccess: (result, variables, onMutateResult, context) => {
// Replace optimistic todo in the todos list with the result
context.client.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === onMutateResult.optimisticTodo.id ? result : todo,
),
)
},
onError: (error, variables, onMutateResult, context) => {
// Remove optimistic todo from the todos list
context.client.setQueryData(['todos'], (old) =>
old.filter((todo) => todo.id !== onMutateResult.optimisticTodo.id),
)
},
retry: 3,
})

// Start mutation in some component:
const mutation = useMutation({ mutationKey: ['addTodo'] })
mutation.mutate({ title: 'title' })

// If the mutation has been paused because the device is for example offline,
// Then the paused mutation can be dehydrated when the application quits:
const state = dehydrate(queryClient)

// The mutation can then be hydrated again when the application is started:
hydrate(queryClient, state)

// Resume the paused mutations:
queryClient.resumePausedMutations()

오프라인 뮤테이션 영구 저장하기

persistQueryClient 플러그인을 사용하여 오프라인 뮤테이션을 영구 저장하는 경우, 기본 뮤테이션 함수를 제공하지 않으면 페이지를 다시 로드할 때 뮤테이션을 재개할 수 없습니다.

이는 기술적인 제한 사항입니다. 외부 저장소에 영구 저장할 때는 함수를 직렬화할 수 없으므로 뮤테이션의 상태만 영구 저장됩니다. 하이드레이션 후에는 뮤테이션을 트리거하는 컴포넌트가 마운트되지 않았을 수 있으므로 resumePausedMutations를 호출하면 오류가 발생할 수 있습니다: No mutationFn found.

const persister = createSyncStoragePersister({
storage: window.localStorage,
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
})

// we need a default mutation function so that paused mutations can resume after a page reload
queryClient.setMutationDefaults(['todos'], {
mutationFn: ({ id, data }) => {
return api.updateTodo(id, data)
},
})

export default function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
onSuccess={() => {
// resume mutations after initial restore from localStorage was successful
queryClient.resumePausedMutations()
}}
>
<RestOfTheApp />
</PersistQueryClientProvider>
)
}

쿼리와 뮤테이션을 모두 다루는 포괄적인 오프라인 예제도 있습니다.

뮤테이션 범위

기본적으로 모든 뮤테이션은 병렬로 실행되며, 동일한 뮤테이션의 .mutate()를 여러 번 호출해도 마찬가지입니다. 이를 방지하려면 뮤테이션에 id가 포함된 scope를 지정할 수 있습니다. 동일한 scope.id를 가진 모든 뮤테이션은 직렬로 실행됩니다. 즉, 트리거될 때 해당 범위에서 이미 진행 중인 뮤테이션이 있으면 isPaused: true 상태로 시작합니다. 이들은 대기열에 추가되며 대기열에서 차례가 되면 자동으로 재개됩니다.

const mutation = useMutation({
mutationFn: addTodo,
scope: {
id: 'todo',
},
})

추가 자료

뮤테이션에 관한 자세한 내용은 React Query의 뮤테이션을 깊이 다룬 TkDodo의 글을 참조하세요.