Back to topics

5 useMutation: Modify Data Gracefully

What is Mutation

All state management tools essentially do two things: one is to fetch data, and the other is to modify data.

For example, React's own useState Hook, as its name implies, is essentially a form of state management.

const [state, setState] = useState(initialState)

The returned state is used to get data, and setState is used to modify data.

In @tanstack/query, fetching data corresponds to useQuery, and modifying data corresponds to useMutation.

In my understanding, it provides a more convenient hook for POST/PUT/DELETE and other requests.

useMutation

const { mutate, isPending, isSuccess, isError } = useMutation({
  mutationFn: () => {
    return axios.post('/api/user', { name: 'jack' })
  },
  // The following methods are triggered at various stages of mutate
  onMutate: () => {
    console.log('onMutate')
  },
  onSuccess: () => {
    console.log('onSuccess')
  },
  onError: () => {
    console.log('onError')
  },
  onSettled: () => {
    console.log('onSettled')
  },
})

Why not call axios.post(...) directly, but instead call it through useMutation?

Because useMutation allows us to easily obtain the derived states of a mutation, such as isPending, isSuccess, isError, etc. Moreover, it can be used with the retry parameter to automatically retry after a request fails.

Cache Management in Callbacks

We can use callbacks like onSuccess, onError, etc., to conveniently perform extra operations after the mutation succeeds or fails.

It's worth noting that if the Promise returned by mutationFn is rejected, even if the API request succeeds, onSuccess will not be triggered. The throwOnError parameter of useMutation controls whether to throw errors, but the default is false. In other words, if onSuccess never fires, set throwOnError to true to check for errors.

const { mutate } = useMutation({
  mutationFn: () => {
    return axios.post('/api/user', { name: 'jack' })
  },
  onSuccess: (newUser) => {
    toast.success('User created successfully')
    // After creating the user successfully, update the user list in cache
    queryClient.setQueryData(['users', newUser.id], newUser)
    // Or after successful creation, invalidate the previous cached data and refetch (i.e., refresh cache)
    queryClient.invalidateQueries({ queryKey: ['users'] })
  },
})

setQueryData is used to manually set cache data, and invalidateQueries is used to invalidate the previously cached data based on the queryKey and refetch it (i.e., refresh the cache).

These APIs are very useful for managing cache state on the front end and can effectively improve user experience.

Optimistic Updates

Usually, after submitting a mutation, we need to show a loading state to the user, wait for the backend response, and ensure the request succeeds before updating the UI.

But this leads to a poor user experience.

How can we optimize? In fact, in many cases, we can predict the UI changes after a successful request without waiting for the backend. If the server tells us the request failed, we can just notify the user "Request failed, please retry later" and roll back the UI.

This is optimistic updates.

The following scenarios are very suitable:

  • Like
  • Bookmark
  • Follow
  • Comment
  • Delete

Basic Implementation

Since it's an optimistic update, we shouldn't wait for the backend response to modify the state. In other words, we should update the state in onMutate instead of in onSuccess.

How to modify the state? queryClient provides the setQueryData method, which allows us to modify the cached data.

The next question to consider is: if the network request fails, how do we roll back the state? queryClient provides the getQueryData method, which allows us to get the cached data.

Based on the queryKey, we can save the current cache state as a snapshot before optimistically updating, and then return a rollback function from the onMutate method. The third parameter of the onError function is context, which is the return value of onMutate; in this case, it is the rollback function.

Similar to Promise, this API also provides onSettled, which runs regardless of success or failure. We can call invalidateQueries inside it to refresh the cache.

onMutate: async ({ targetData }) => {
  const snapshot = queryClient.getQueryData(queryKey)
  // Optimistic update
  queryClient.setQueryData(queryKey, (oldData) => {
    // handle old data and then
    return newData
  })
  // Rollback
  return () => queryClient.setQueryData(queryKey, snapshot)
}
onError: (err, variables, rollback) => {
  rollback?.()
}

onSettled: () => {
  return queryClient.invalidateQueries({
    queryKey,
  })
}

Real Code

export type Todo = {
  id: number
  title: string
  description: string
  completed: boolean
}

const useCheckTodo = () => {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: ({ todo }: { todo: Todo }) => checkTodo(todo),
    onMutate: ({ todo }) => {
      const snapshot = queryClient.getQueryData(['todoList'])
      queryClient.setQueryData(['todoList'], (oldData: Todo[]) => {
        return oldData.map((t) => (t.id === todo.id ? { ...t, completed: !t.completed } : t))
      })
      /**
       * The value returned from this function will be passed to both the onError and onSettled functions in the event of a mutation failure and can be useful for rolling back optimistic updates.
       */
      return () => {
        queryClient.setQueryData(['todoList'], snapshot)
      }
    },
    onError: (error, todo, rollback) => {
      rollback?.()
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todoList'] })
    },
  })
}

Hook Wrapper

If optimistic updates are frequently used, we can wrap them into a Hook for reusability.

However, I think there aren't many places in a project that require optimistic updates. Implementing optimistic updates with useMutation's onMutate, onError, and onSettled is already convenient. Wrapping it into a Hook might increase code complexity instead.

export const useOptimisticMutation = ({ mutationFn, queryKey, updater, invalidates }) => {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn,
    onMutate: async () => {
      // Cancel any related requests to avoid overwriting results
      await queryClient.cancelQueries({
        queryKey,
      })

      const snapshot = queryClient.getQueryData(queryKey)

      queryClient.setQueryData(queryKey, updater)

      return () => {
        queryClient.setQueryData(queryKey, snapshot)
      }
    },
    onError: (err, variables, rollback) => {
      rollback?.()
    },
    onSettled: () => {
      return queryClient.invalidateQueries({
        queryKey: invalidates,
      })
    },
  })
}