React useMutate:处理加载、错误与并发请求

React useMutate:处理加载、错误与并发请求

React 版 useMutate 适合封装由事件触发的异步操作。早期实现里最容易被忽略的问题是:普通局部变量不会触发渲染,失败时塞入 {} as T 会破坏类型,并发请求也可能让旧结果覆盖新结果。

一个最小但完整的实现

import {
  useCallback,
  useEffect,
  useRef,
  useState
} from 'react'

interface UseMutateOptions<TData> {
  initialData?: TData
}

interface MutateState<TData> {
  data: TData | undefined
  error: unknown
  loading: boolean
}

export function useMutate<
  TData,
  TArgs extends unknown[]
>(
  requester: (...args: TArgs) => Promise<TData>,
  options: UseMutateOptions<TData> = {}
) {
  const { initialData } = options

  const [state, setState] = useState<MutateState<TData>>({
    data: initialData,
    error: undefined,
    loading: false
  })

  const latestRequestIdRef = useRef(0)
  const mountedRef = useRef(true)

  useEffect(() => {
    mountedRef.current = true

    return () => {
      mountedRef.current = false
      latestRequestIdRef.current += 1
    }
  }, [])

  const mutate = useCallback(
    async (...args: TArgs): Promise<TData> => {
      const requestId = ++latestRequestIdRef.current

      setState((previous) => ({
        ...previous,
        error: undefined,
        loading: true
      }))

      try {
        const data = await requester(...args)

        if (
          mountedRef.current &&
          requestId === latestRequestIdRef.current
        ) {
          setState({
            data,
            error: undefined,
            loading: false
          })
        }

        return data
      } catch (error) {
        if (
          mountedRef.current &&
          requestId === latestRequestIdRef.current
        ) {
          setState((previous) => ({
            ...previous,
            error,
            loading: false
          }))
        }

        throw error
      }
    },
    [requester]
  )

  const reset = useCallback(() => {
    latestRequestIdRef.current += 1
    setState({
      data: initialData,
      error: undefined,
      loading: false
    })
  }, [initialData])

  return {
    ...state,
    mutate,
    reset
  }
}

使用

function UserDetail({ userId }: { userId: string }) {
  const requestUser = useCallback(
    (id: string) => userService.getById(id),
    []
  )

  const {
    data,
    error,
    loading,
    mutate
  } = useMutate(requestUser)

  const handleLoad = async () => {
    try {
      await mutate(userId)
    } catch {
      // Hook 已记录 error;这里决定是否 toast。
    }
  }

  return (
    <section>
      <button disabled={loading} onClick={handleLoad}>
        {loading ? '加载中…' : '加载用户'}
      </button>

      {error && <p role="alert">加载失败,请重试。</p>}
      {data && <UserCard user={data} />}
    </section>
  )
}

传给 Hook 的 requester 应使用稳定引用,否则 mutate 每次渲染都会变化。是否真的需要稳定引用,要看它是否继续作为子组件属性或 Effect 依赖,不能为了 useCallback 而滥用 useCallback

状态语义

  • loading 必须由 useState 或 reducer 管理,普通 let loading = false 不会让界面更新。
  • dataTData | undefined 表达“尚无成功结果”,不要用伪造的空对象。
  • 失败的 Promise 继续抛出,让事件处理器、表单库或错误边界决定后续动作。
  • 示例保留上一次成功数据。若产品希望重新请求时显示骨架屏,可在请求开始时清空 data
  • 最新调用才能写状态,避免快速切换条件时出现结果倒退。

“忽略旧结果”没有取消网络请求。如果接口库支持 AbortSignal,高频查询应把取消信号传到底层。

自动请求不是 mutate 的默认职责

若组件挂载或参数变化时自动获取数据,应在调用处写清 Effect:

useEffect(() => {
  void mutate(userId).catch(() => {})
}, [mutate, userId])

React 开发模式的 Strict Mode 可能额外执行 Effect 的设置与清理,用来暴露不可逆副作用。请求需要具备取消、去重或幂等边界,不能依赖“Effect 只执行一次”的假设。

当需求包含缓存、预取、失焦刷新、乐观更新、请求去重或分页时,优先评估 TanStack Query、SWR 等专门的数据层。这个 Hook 的价值是把一个小型命令式请求写清楚,而不是替代完整的服务端状态管理。

最后更新于

ihopeful Blog 由博主亲笔撰写,重要信息可放心引用。