🔥 낙관적 업데이트

705자
8분

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
 
typescript

이제 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>
 
typescript

뮤테이션이 대기 중인 동안 다른 투명도로 임시 항목을 렌더링합니다. 완료되면 이 항목은 자동으로 더 이상 렌더링되지 않습니다. 리패치가 성공했다면 목록에서 "일반 항목"으로 이 항목을 볼 수 있을 것입니다.

뮤테이션이 실패하면 항목도 사라집니다. 하지만 원한다면 뮤테이션의 isError 상태를 확인하여 계속 표시할 수 있습니다. 뮤테이션이 실패해도 variables는 지워지지 않으므로 여전히 접근할 수 있으며, 심지어 재시도 버튼을 표시할 수도 있습니다:

{
  isError && (
    <li style={{ color: 'red' }}>
      {variables}
      <button onClick={() => mutate(variables)}>재시도</button>
    </li>
  )
}
 
typescript

뮤테이션과 쿼리가 같은 컴포넌트에 없는 경우

이 방법은 뮤테이션과 쿼리가 같은 컴포넌트에 있을 때 잘 작동합니다. 하지만 전용 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,
})
 
typescript

variables는 배열이 될 것입니다. 동시에 여러 뮤테이션이 실행될 수 있기 때문입니다. 항목에 고유한 키가 필요하다면 mutation.state.submittedAt도 선택할 수 있습니다. 이렇게 하면 동시에 발생하는 낙관적 업데이트도 쉽게 표시할 수 있습니다.

캐시를 통한 방법

뮤테이션을 수행하기 전에 상태를 낙관적으로 업데이트할 때, 뮤테이션이 실패할 가능성이 있습니다. 대부분의 실패 사례에서는 낙관적 쿼리를 리패치하여 실제 서버 상태로 되돌릴 수 있습니다. 하지만 일부 상황에서는 리패치가 제대로 작동하지 않을 수 있으며, 뮤테이션 오류가 리패치를 불가능하게 만드는 서버 문제를 나타낼 수 있습니다. 이런 경우에는 업데이트를 롤백하는 방법을 선택할 수 있습니다.

이를 위해 useMutationonMutate 핸들러 옵션을 사용하면 나중에 onErroronSettled 핸들러에 마지막 인자로 전달될 값을 반환할 수 있습니다. 대부분의 경우 롤백 함수를 전달하는 것이 가장 유용합니다.

새로운 할 일을 추가할 때 할 일 목록 업데이트하기

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'] })
  },
})
 
typescript

단일 할 일 업데이트하기

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] })
  },
})
 
typescript

원한다면 별도의 onErroronSuccess 핸들러 대신 onSettled 함수를 사용할 수도 있습니다:

useMutation({
  mutationFn: updateTodo,
  // ...
  onSettled: (newTodo, error, variables, context) => {
    if (error) {
      // 무언가를 수행합니다
    }
  },
})
 
typescript

언제 어떤 방법을 사용해야 할까

낙관적 결과를 보여줄 곳이 한 군데뿐이라면, variables를 사용하고 UI를 직접 업데이트하는 방법이 더 적은 코드를 필요로 하며 일반적으로 이해하기 쉽습니다. 예를 들어, 롤백을 전혀 처리할 필요가 없습니다.

하지만 화면의 여러 곳에서 업데이트를 알아야 한다면, 캐시를 직접 조작하는 방법이 이를 자동으로 처리해 줄 것입니다.