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不会让界面更新。data用TData | undefined表达“尚无成功结果”,不要用伪造的空对象。- 失败的 Promise 继续抛出,让事件处理器、表单库或错误边界决定后续动作。
- 示例保留上一次成功数据。若产品希望重新请求时显示骨架屏,可在请求开始时清空
data。 - 最新调用才能写状态,避免快速切换条件时出现结果倒退。
“忽略旧结果”没有取消网络请求。如果接口库支持 AbortSignal,高频查询应把取消信号传到底层。
自动请求不是 mutate 的默认职责
若组件挂载或参数变化时自动获取数据,应在调用处写清 Effect:
useEffect(() => {
void mutate(userId).catch(() => {})
}, [mutate, userId])
React 开发模式的 Strict Mode 可能额外执行 Effect 的设置与清理,用来暴露不可逆副作用。请求需要具备取消、去重或幂等边界,不能依赖“Effect 只执行一次”的假设。
当需求包含缓存、预取、失焦刷新、乐观更新、请求去重或分页时,优先评估 TanStack Query、SWR 等专门的数据层。这个 Hook 的价值是把一个小型命令式请求写清楚,而不是替代完整的服务端状态管理。