본문 바로가기

Front-End: Web

React Query: Optimistic Updates

반응형

Optimistic Updates란

사용자의 동작으로 useMutation을 실행할 때, mutate가 실행되고 다시 useQuery를 실행해서 데이터를 받아오기까지 시간이 걸린다. 그런데 이 시간동안 웹 화면상에서는 변화가 없으니 유저들은 변화가 있기 짧은 시간동안 기다리게 된다. 이 불편함을 없애기 위해 사용되는 것이 react query의 Optimistic Updates다.

왜 이름이 Optimistic(낙관적인) Updates냐 하면, state를 optimistically(낙관적으로)하게 업데이트하기 때문이다. 즉, 무조건 성공했다고 가정하고 state 값을 변화시켜버린다. 그래서 낙관적인 업데이트다.

하지만 업데이트가 실패할 가능성도 있다. 그래서 실패한 경우에는 변경되기 이전의 데이터를 다시 가져오고 (refetch) 실제 server state값을 가져온다. 그러나 일부 상황에서는 데이터를 refetch할 때 제대로 동작하지 않을 수도 있는데 이 경우를 대비하여 mutation error는 refetch가 불가한 서버 이슈를 나타낼 수 있다. 이 경우에는 업데이트를 롤백하면 된다.

그래서 Optimistic Updates를 하기 위해선 useMutation 의 onMutate handler를 사용하면, 나중에 onError 와 onSettled handler에 마지막 인수로 전달된 값을 return해줄 수 있다. 이걸 응용하면 롤백 기능을 구현할 수 있다 (젤 많이 사용되는 기능임).

  • cancelQueries: 특정 query key에 해당하는 데이터의 다른 업데이트를 무시한다.
  • getQueryData: cache에 저장되어 있는 데이터들을 가져온다.
  • setQueryData: mutation을 성공했을 때 쿼리 데이터를 명시적으로 바꿔준
  • invalidateQueries: 파라미터로 넣어준 query key에 해당하는 쿼리를 명시적으로 stale하다고 알려주고, 해당 쿼리 데이터를 새로 받아온다(refetch)
    • refetch는 데이터의 stale 여부와 상관없이 항상 refetch하지만, invalidateQueries는 기존 데이터를 stale로 변경 후 마운트되어야 refetch가 동작한다. (mutation이 끝나기 전에 ui가 unmount되면 제대로 동작하지 않음)

 

case 1: 새로운 todo를 리스트에 추가할 때(Create)

const queryClient = useQueryClient()

useMutation({
  mutationFn: updateTodo,
  // When mutate is called:
  onMutate: async (newTodo) => {
    // Cancel any outgoing refetches
    // (so they don't overwrite our optimistic update)
    await queryClient.cancelQueries({ queryKey: ['todos'] })

    // Snapshot the previous value
    const previousTodos = queryClient.getQueryData(['todos'])

    // Optimistically update to the new value
    queryClient.setQueryData(['todos'], (old) => [...old, newTodo])

    // Return a context object with the snapshotted value
    return { previousTodos }
  },
  // If the mutation fails,
  // use the context returned from onMutate to roll back
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos)
  },
  // Always refetch after error or success:
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

onMutate

  1. cancelQueries: 다른 업데이트들을 먼저 무시하고,
  2. getQueryData: ‘todos’ query key로 저장된 cache 데이터를 가져와서 저장하고,
  3. setQueryData: 이전 데이터와 새로운 데이터를 합쳐 cache에 넣는다.
  4. onError에서 사용하기 위해 previousTodos를 리턴해준다.

onError

  1. onMutate에서 previousTodos를 가져와서 cach에 넣어 롤백한다.

onSettled

  1. onError, onSuccess 됐던지, 쿼리 데이터를 새로 받아온다.

 

case 2: 하나의 todo를 수정할 때(Update)

useMutation({
  mutationFn: updateTodo,
  // When mutate is called:
  onMutate: async (newTodo) => {
    // Cancel any outgoing refetches
    // (so they don't overwrite our optimistic update)
    await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })

    // Snapshot the previous value
    const previousTodo = queryClient.getQueryData(['todos', newTodo.id])

    // Optimistically update to the new value
    queryClient.setQueryData(['todos', newTodo.id], newTodo)

    // Return a context with the previous and new todo
    return { previousTodo, newTodo }
  },
  // If the mutation fails, use the context we returned above
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(
      ['todos', context.newTodo.id],
      context.previousTodo,
    )
  },
  // Always refetch after error or success:
  onSettled: (newTodo) => {
    queryClient.invalidateQueries({ queryKey: ['todos', newTodo.id] })
  },
})

onMutate

  1. cancelQueries: 다른 업데이트들을 무시하고,
  2. getQueryData: cache에 저장된 이전 데이터를 받아오고 previousTodo에 저장한다.
  3. setQueryData: 업데이트된 newTodo 데이터를 cache에 저장하고,
  4. onError에서 필요한 데이터를 리턴으로 넘겨준다.

onError

  1. setQueryData: 실패했으므로 이전 데이터를 다시 cache에 저장하여 롤백시켜준다.

onSettled

  1. invalidateQueries: query key에 해당되는 쿼리의 데이터를 새로 받아온다.

 

참고 코드: 유저 프로필 변경(Update)

return 값으로 previousProfile을 전달하고, onError에서 받아 데이터를 롤백시켜준다.

const { mutate: updateProfileData } = useMutation(patchUserProfile, {
    onMutate: async (newProfile) => {
      await queryClient.cancelQueries({
        queryKey: [USER_PROFILE, profileUserId],
      });
      const previousProfile = queryClient.getQueriesData([USER_PROFILE]);
      queryClient.setQueriesData<UserProfileType | undefined>(
        [USER_PROFILE],
        newProfile
      );

      return { newProfile, previousProfile };
    },
    onError: (_err, _newProfile, context) => {
      queryClient.setQueriesData([USER_PROFILE], context?.previousProfile);
    },
    onSettled: () => {
      queryClient.invalidateQueries({
        queryKey: [USER_PROFILE, profileUserId],
      });
    },
  });
반응형