https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
TanStack | High Quality Open-Source Software for Web Developers
Headless, type-safe, powerful utilities for complex workflows like Data Management, Data Visualization, Charts, Tables, and UI Components.
tanstack.com
공식 문서를 번역 정리한 글 입니다.
낙관적 업데이트는 mutation 이 성공적으로 반영될 것이라 낙관적으로 판단을 하고
서버로 부터 응답이 오기 전에 UI 업데이트를 먼저 하는 것을 말합니다.
React Query는 뮤테이션이 완료되기 전에 UI를 낙관적으로 업데이트하는 두 가지 방법을 제공합니다:
1. onMutate 옵션을 사용하여 캐시를 직접 업데이트
2. useMutation 결과의 반환된 variables 를 활용하여 UI 업데이트
1. UI를 통한 낙관적 업데이트
- 이 방법은 캐시와 직접 상호작용하지 않아 더 간단합니다
const addTodoMutation = useMutation({
mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
// 쿼리 무효화의 Promise를 반드시 반환하여
// 리패치가 완료될 때까지 뮤테이션이 'pending' 상태를 유지하도록 함
onSettled: async () => {
return await queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
const { isPending, submittedAt, variables, mutate, isError } = addTodoMutation
// onSettled 은 onSuccess 와 onError 이 결합된 것과 같습니다
쿼리가 렌더링되는 UI 리스트에서 뮤테이션이 isPending 상태일 때 목록에 항목을 추가할 수 있습니다:
<ul>
{todoQuery.items.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
</ul>
뮤테이션이 진행 중일 때는 다른 투명도로 임시 항목을 렌더링합니다.
완료되면 자동으로 해당 항목이 더 이상 렌더링되지 않습니다.
오류가 발생하면 항목이 사라지지만, 뮤테이션의 isError 상태를 확인하여 계속 표시할 수도 있습니다:
{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'],
})
// 다른 곳에서 variables 접근
const variables = useMutationState<string>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables,
})
variables는 배열이 됩니다. 동시에 여러 뮤테이션이 실행될 수 있기 때문입니다.
항목에 고유 키가 필요한 경우 mutation.state.submittedAt을 선택할 수도 있습니다.
function TodoList() {
const todoQuery = useQuery({
queryKey: ['todos'],
queryFn: () => axios.get('/api/todos')
})
// submittedAt 타임스탬프를 키로 사용하여 낙관적 업데이트 상태 추적
const pendingTodos = useMutationState({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => ({
text: mutation.state.variables,
submittedAt: mutation.state.submittedAt
})
})
return (
<ul>
{/* 기존 할일 목록 */}
{todoQuery.data?.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
{/* 대기 중인 낙관적 업데이트 항목들 */}
{pendingTodos.map((pendingTodo) => (
<li
key={pendingTodo.submittedAt}
style={{ opacity: 0.5 }}
>
{pendingTodo.text} (처리 중...)
</li>
))}
</ul>
)
}
2. 캐시를 통한 낙관적 업데이트
캐시를 통한 낙관적 업데이트는 더 복잡하지만, 다음과 같은 장점이 있습니다:
- UI 로직이 더 간단해집니다 - 뮤테이션 상태를 확인할 필요가 없습니다
- 낙관적으로 업데이트된 데이터가 여러 컴포넌트에서 즉시 사용 가능합니다
- 실패 시 자동으로 롤백됩니다
다음은 할일 목록에 새 항목을 추가하는 예제입니다:
const queryClient = useQueryClient()
const addTodoMutation = useMutation({
mutationFn: (newTodo: string) =>
axios.post('/api/data', { text: newTodo }),
// 낙관적 업데이트를 위해 onMutate 사용
onMutate: async (newTodo) => {
// 백그라운드 리패치 취소
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 현재 값의 스냅샷 저장
const previousTodos = queryClient.getQueryData(['todos'])
// 새 할일로 캐시 업데이트
queryClient.setQueryData(['todos'], (old: Todo[]) => [
...old,
{ id: 'temp-id', text: newTodo },
])
// 스냅샷 반환
return { previousTodos }
},
// 에러 발생 시 롤백
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos)
},
// 성공 또는 실패 후 리패치
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
이제 UI는 매우 간단해집니다:
<ul>
{todoQuery.data.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
3. 낙관적 업데이트 시 고려사항
- 임시 ID: 새로운 항목에는 임시 ID가 필요할 수 있습니다. 서버에서 실제 ID를 받기 전까지 사용됩니다.
- 동시성 문제: 여러 업데이트가 동시에 발생할 수 있으므로, 각 업데이트의 컨텍스트를 올바르게 관리해야 합니다.
- 롤백 로직: 실패 시 이전 상태로 정확하게 되돌릴 수 있어야 합니다.
- 리패치 타이밍: onSettled에서 쿼리를 무효화하여 최신 서버 데이터를 가져오는 것이 좋습니다.
4. 타입스크립트 예제
interface Todo {
id: string
text: string
}
interface Context {
previousTodos: Todo[]
}
const addTodoMutation = useMutation<
// 응답 타입
{ id: string; text: string },
// 에러 타입
Error,
// 변수 타입
string,
// 컨텍스트 타입
Context
>({
mutationFn: (newTodo) =>
axios.post('/api/todos', { text: newTodo }),
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']) ?? []
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
...old,
{ id: 'temp-id', text: newTodo },
])
return { previousTodos }
},
onError: (err, newTodo, context) => {
if (context) {
queryClient.setQueryData<Todo[]>(['todos'], context.previousTodos)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})