🔥 낙관적 업데이트
강의 목차
React Query는 뮤테이션이 완료되기 전에 UI를 낙관적으로 업데이트하는 두 가지 방법을 제공합니다. onMutate 옵션을 사용하여 캐시를 직접 업데이트하거나, useMutation 결과에서 반환된 변수를 활용하여 UI를 업데이트할 수 있습니다.
UI를 통한 방법
이 방법은 캐시와 직접 상호작용하지 않기 때문에 더 간단합니다.
const addTodoMutation = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
// 쿼리 무효화에서 Promise를 반드시 반환해야 합니다.
// 이렇게 하면 리패치가 완료될 때까지 뮤테이션이 '대기' 상태를 유지합니다.
onSettled: async () => {
return await queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
const { isPending, submittedAt, variables, mutate, isError } = addTodoMutation
const addTodoMutation = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
// 쿼리 무효화에서 Promise를 반드시 반환해야 합니다.
// 이렇게 하면 리패치가 완료될 때까지 뮤테이션이 '대기' 상태를 유지합니다.
onSettled: async () => {
return await queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
const { isPending, submittedAt, variables, mutate, isError } = addTodoMutation
이제 addTodoMutation.variables에 접근할 수 있으며, 이는 추가된 할 일을 포함합니다. 쿼리가 렌더링되는 UI 목록에서 뮤테이션이 isPending 상태일 때 목록에 다른 항목을 추가할 수 있습니다:
<ul>
{todoQuery.items.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
</ul>
<ul>
{todoQuery.items.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
</ul>
뮤테이션이 대기 중인 동안 다른 투명도로 임시 항목을 렌더링합니다. 완료되면 이 항목은 자동으로 더 이상 렌더링되지 않습니다. 리패치가 성공했다면 목록에서 "일반 항목"으로 이 항목을 볼 수 있을 것입니다.
뮤테이션이 실패하면 항목도 사라집니다. 하지만 원한다면 뮤테이션의 isError 상태를 확인하여 계속 표시할 수 있습니다. 뮤테이션이 실패해도 variables는 지워지지 않으므로 여전히 접근할 수 있으며, 심지어 재시도 버튼을 표시할 수도 있습니다:
{
isError && (
<li style={{ color: 'red' }}>
{variables}
<button onClick={() => mutate(variables)}>재시도</button>
</li>
)
}
{
isError && (
<li style={{ color: 'red' }}>
{variables}
<button onClick={() => mutate(variables)}>재시도</button>
</li>
)
}
뮤테이션과 쿼리가 같은 컴포넌트에 없는 경우
이 방법은 뮤테이션과 쿼리가 같은 컴포넌트에 있을 때 잘 작동합니다. 하지만 전용 useMutationState 훅을 통해 다른 컴포넌트에서도 모든 뮤테이션에 접근할 수 있습니다. mutationKey와 함께 사용하는 것이 가장 좋습니다:
// 앱의 어딘가에서
const { mutate } = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
mutationKey: ['addTodo'],
})
// 다른 곳에서 변수에 접근
const variables = useMutationState<string>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables,
})
// 앱의 어딘가에서
const { mutate } = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
mutationKey: ['addTodo'],
})
// 다른 곳에서 변수에 접근
const variables = useMutationState<string>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables,
})
variables는 배열이 될 것입니다. 동시에 여러 뮤테이션이 실행될 수 있기 때문입니다. 항목에 고유한 키가 필요하다면 mutation.state.submittedAt도 선택할 수 있습니다. 이렇게 하면 동시에 발생하는 낙관적 업데이트도 쉽게 표시할 수 있습니다.
캐시를 통한 방법
뮤테이션을 수행하기 전에 상태를 낙관적으로 업데이트할 때, 뮤테이션이 실패할 가능성이 있습니다. 대부분의 실패 사례에서는 낙관적 쿼리를 리패치하여 실제 서버 상태로 되돌릴 수 있습니다. 하지만 일부 상황에서는 리패치가 제대로 작동하지 않을 수 있으며, 뮤테이션 오류가 리패치를 불가능하게 만드는 서버 문제를 나타낼 수 있습니다. 이런 경우에는 업데이트를 롤백하는 방법을 선택할 수 있습니다.
이를 위해 useMutation의 onMutate 핸들러 옵션을 사용하면 나중에 onError와 onSettled 핸들러에 마지막 인자로 전달될 값을 반환할 수 있습니다. 대부분의 경우 롤백 함수를 전달하는 것이 가장 유용합니다.
새로운 할 일을 추가할 때 할 일 목록 업데이트하기
const queryClient = useQueryClient()
useMutation({
mutationFn: updateTodo,
// mutate가 호출될 때:
onMutate: async (newTodo) => {
// 진행 중인 리패치를 취소합니다
// (낙관적 업데이트를 덮어쓰지 않도록)
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 이전 값의 스냅샷을 만듭니다
const previousTodos = queryClient.getQueryData(['todos'])
// 새로운 값으로 낙관적으로 업데이트합니다
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
// 스냅샷된 값을 포함한 컨텍스트 객체를 반환합니다
return { previousTodos }
},
// 뮤테이션이 실패하면,
// onMutate에서 반환된 컨텍스트를 사용하여 롤백합니다
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
// 오류나 성공 후 항상 리패치합니다:
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
const queryClient = useQueryClient()
useMutation({
mutationFn: updateTodo,
// mutate가 호출될 때:
onMutate: async (newTodo) => {
// 진행 중인 리패치를 취소합니다
// (낙관적 업데이트를 덮어쓰지 않도록)
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 이전 값의 스냅샷을 만듭니다
const previousTodos = queryClient.getQueryData(['todos'])
// 새로운 값으로 낙관적으로 업데이트합니다
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])
// 스냅샷된 값을 포함한 컨텍스트 객체를 반환합니다
return { previousTodos }
},
// 뮤테이션이 실패하면,
// onMutate에서 반환된 컨텍스트를 사용하여 롤백합니다
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
// 오류나 성공 후 항상 리패치합니다:
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
단일 할 일 업데이트하기
useMutation({
mutationFn: updateTodo,
// mutate가 호출될 때:
onMutate: async (newTodo) => {
// 진행 중인 리패치를 취소합니다
// (낙관적 업데이트를 덮어쓰지 않도록)
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })
// 이전 값의 스냅샷을 만듭니다
const previousTodo = queryClient.getQueryData(['todos', newTodo.id])
// 새로운 값으로 낙관적으로 업데이트합니다
queryClient.setQueryData(['todos', newTodo.id], newTodo)
// 이전 할 일과 새로운 할 일을 포함한 컨텍스트를 반환합니다
return { previousTodo, newTodo }
},
// 뮤테이션이 실패하면, 위에서 반환한 컨텍스트를 사용합니다
onError: (err, newTodo, context) => {
queryClient.setQueryData(
['todos', context.newTodo.id],
context.previousTodo,
)
},
// 오류나 성공 후 항상 리패치합니다:
onSettled: (newTodo) => {
queryClient.invalidateQueries({ queryKey: ['todos', newTodo.id] })
},
})
useMutation({
mutationFn: updateTodo,
// mutate가 호출될 때:
onMutate: async (newTodo) => {
// 진행 중인 리패치를 취소합니다
// (낙관적 업데이트를 덮어쓰지 않도록)
await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })
// 이전 값의 스냅샷을 만듭니다
const previousTodo = queryClient.getQueryData(['todos', newTodo.id])
// 새로운 값으로 낙관적으로 업데이트합니다
queryClient.setQueryData(['todos', newTodo.id], newTodo)
// 이전 할 일과 새로운 할 일을 포함한 컨텍스트를 반환합니다
return { previousTodo, newTodo }
},
// 뮤테이션이 실패하면, 위에서 반환한 컨텍스트를 사용합니다
onError: (err, newTodo, context) => {
queryClient.setQueryData(
['todos', context.newTodo.id],
context.previousTodo,
)
},
// 오류나 성공 후 항상 리패치합니다:
onSettled: (newTodo) => {
queryClient.invalidateQueries({ queryKey: ['todos', newTodo.id] })
},
})
원한다면 별도의 onError와 onSuccess 핸들러 대신 onSettled 함수를 사용할 수도 있습니다:
useMutation({
mutationFn: updateTodo,
// ...
onSettled: (newTodo, error, variables, context) => {
if (error) {
// 무언가를 수행합니다
}
},
})
useMutation({
mutationFn: updateTodo,
// ...
onSettled: (newTodo, error, variables, context) => {
if (error) {
// 무언가를 수행합니다
}
},
})
언제 어떤 방법을 사용해야 할까
낙관적 결과를 보여줄 곳이 한 군데뿐이라면, variables를 사용하고 UI를 직접 업데이트하는 방법이 더 적은 코드를 필요로 하며 일반적으로 이해하기 쉽습니다. 예를 들어, 롤백을 전혀 처리할 필요가 없습니다.
하지만 화면의 여러 곳에서 업데이트를 알아야 한다면, 캐시를 직접 조작하는 방법이 이를 자동으로 처리해 줄 것입니다.










