본문 바로가기

TanStack Query

[TanStack Query / React Query] Optimistic Updates ( 낙관적 업데이트 ) : UI 방식과 캐시 방식

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. 낙관적 업데이트 시 고려사항

 

  1. 임시 ID: 새로운 항목에는 임시 ID가 필요할 수 있습니다. 서버에서 실제 ID를 받기 전까지 사용됩니다.
  2. 동시성 문제: 여러 업데이트가 동시에 발생할 수 있으므로, 각 업데이트의 컨텍스트를 올바르게 관리해야 합니다.
  3. 롤백 로직: 실패 시 이전 상태로 정확하게 되돌릴 수 있어야 합니다.
  4. 리패치 타이밍: 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'] })
  },
})