返回文章列表

5. useMutation 优雅地修改数据

什么是 Mutation

所有状态管理工具本质上都是在做两件事:一个是获取数据,一个是修改数据

比如 React 自己的useState Hook,顾名思义,它本质也是一种状态管理。

const [state, setState] = useState(initialState)

它返回的state 是用于获取数据的,setState 是用于修改数据的。

@tanstack/query 中,获取数据对应的是useQuery修改数据对应的是useMutation

我个人理解,就是为 POST/PUT/DELETE 等请求提供了一个更方便的钩子。

useMutation

const { mutate, isPending, isSuccess, isError } = useMutation({
  mutationFn: () => {
    return axios.post('/api/user', { name: 'jack' })
  },
  // 以下方法在 mutate 各个阶段触发
  onMutate: () => {
    console.log('onMutate')
  },
  onSuccess: () => {
    console.log('onSuccess')
  },
  onError: () => {
    console.log('onError')
  },
  onSettled: () => {
    console.log('onSettled')
  },
})

为什么不直接调用 axios.post(...),而是要通过 useMutation 来调用?

因为 useMutation 可以让我们很方便地获取到 mutation 的衍生状态,比如 isPendingisSuccessisError 等。而且可以配合 retry 参数,在请求失败后自动重试。

回调函数中做缓存管理

我们可以通过 onSuccessonError 等回调函数,在 mutation 成功或失败后,更方便地做一些额外的操作。

值得注意的是,如果 mutationFn 中返回的 Promise 是 reject 的,那么就算接口请求成功了,onSuccess 也不会触发。useMutationthrowOnError 参数可以控制是否抛出错误,但默认是 false。换句话说,如果 onSuccess 一直不触发,请将 throwOnError 设为 true,查看是否报错。

const { mutate } = useMutation({
  mutationFn: () => {
    return axios.post('/api/user', { name: 'jack' })
  },
  onSuccess: (newUser) => {
    toast.success('User created successfully')
    // 创建用户成功后,更新缓存中的用户列表
    queryClient.setQueryData(['users', newUser.id], newUser)
    // 或者创建用户成功后,让之前的缓存数据失效,并重新获取数据(即刷新缓存)
    queryClient.invalidateQueries({ queryKey: ['users'] })
  },
})

setQueryData 是用于手动设置缓存数据,invalidateQueries 是用于让之前基于 queryKey 缓存的数据失效并重新获取数据(即刷新缓存)。

这些 API 对于前端管理缓存状态非常有用,可以有效提升用户体验。

乐观更新 Optimistic Updates

通常我们提交一个 mutation 后,需要给用户一个 loading 状态,并等待后端返回结果,确保请求成功后才能更新 UI。

但这样用户体验很不流畅。

我们该如何优化呢?其实,在很多情况下,我们不需要等待后端返回结果就已经可以预知请求成功后的 UI 变化。如果服务器告诉我们请求失败了,大不了提示用户“请求失败稍后重试”,然后回滚 UI。

这就是 optimistic updates,也就是乐观更新

以下这些场景非常适合:

  • 点赞

  • 收藏

  • 关注

  • 评论

  • 删除

基础实现

既然是乐观更新,那就不能等后端返回结果再修改状态了。换句话说,我们不应该在 onSuccess 中修改状态,而是应该在 onMutate 中修改状态。

怎么修改状态呢?queryClient 提供了 setQueryData 方法,可以让我们修改缓存的数据。

接下来要思考的问题是,如果网络请求失败了,我们该如何回滚状态?queryClient 提供了 getQueryData 方法,可以让我们获取缓存的数据。

基于 queryKey,我们可以在乐观地修改状态前先保存目前缓存的状态作为快照,再在 onMutate 方法中返回一个回滚方法。onError 函数的第三个参数是 context,它是 onMutate 的返回值,在本例中就是回滚方法。

类似 Promise,该 API 还提供了 onSettled,它无论接口成功与否,都会执行。我们可以在里面调用 invalidateQueries,刷新缓存。

onMutate: async ({ targetData }) => {
  const snapshot = queryClient.getQueryData(queryKey)
  // 乐观更新
  queryClient.setQueryData(queryKey, (oldData) => {
    //handle old data and then
    return newData
  })
  // 回滚
  return () => queryClient.setQueryData(queryKey, snapshot)
}
onError: (err, variables, rollback) => {
  rollback?.()
}

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

真实代码

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 封装

如果常用乐观更新这种操作,我们可以将它封装成一个 Hook,方便复用。

但我觉得一个项目中涉及到乐观更新的地方其实并不多,基于 useMutation 调用 onMutateonErroronSettled 等方法,可以很方便地实现乐观更新。封装了 Hook 后,反而增加了代码的复杂度。

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

  return useMutation({
    mutationFn,
    onMutate: async () => {
      // 取消任何相关请求,避免覆盖结果
      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,
      })
    },
  })
}