React 18中如何正确获取数据并存入Zustand Store
首先明确核心逻辑:React 18严格模式开发环境下useEffect双执行,本质是框架主动帮你排查缺少清理逻辑的副作用问题,不是禁止在useEffect中写数据请求逻辑。你之前看到的"不建议在useEffect中获取数据"的说法,本质是反对不带任何竞态处理、清理逻辑的裸请求写法。
你之前React 17的写法问题在于没有做请求去重、中断和过期请求拦截,才会在双调用时出现重复发请求、状态异常的问题,只需要补上对应逻辑即可,不需要完全重构现有代码。
方案1:最小改动适配:给Zustand Action补全请求防重+取消逻辑
这个方案完全兼容你现有的代码组织方式,只需要改造store的请求action,同时给组件useEffect补上清理函数即可。
第一步:改造Zustand Store
核心是给请求加上AbortController支持,重复触发请求时自动中断上一次未完成的请求,同时过滤掉主动取消的请求错误,避免误更新状态:
import { create } from 'zustand'; import axios from 'axios'; const useStore = create((set, get) => ({ items: [], loading: false, error: null, // 内部存储当前请求的取消控制器,不需要对外暴露给组件 _fetchAbortController: null, async fetchItems(page = 1) { // 存在未完成的请求时先主动中断,避免重复请求 const existingController = get()._fetchAbortController; if (existingController) existingController.abort(); // 为本次请求创建新的取消控制器 const newController = new AbortController(); set({ _fetchAbortController: newController, loading: true, error: null }); try { const { data } = await axios.get(`my-url?page=${page}`, { signal: newController.signal // 把取消信号传给axios }); // 只有当前请求未被中断时才更新状态,拦截过期请求的返回 if (!newController.signal.aborted) { set({ loading: false, items: data, _fetchAbortController: null }); } } catch (err) { // 主动取消的请求不需要更新错误、loading状态 if (err.name === 'CanceledError' || axios.isCancel(err)) return; set({ error: "There was an error.", loading: false, _fetchAbortController: null }); } } }));
第二步:调整组件useEffect逻辑,补上清理函数
组件的业务逻辑几乎不需要改动,只需要在useEffect的返回值里加上请求清理逻辑,在effect重跑、组件卸载时自动中断未完成的请求:
function MyComponent() { const items = useStore(state => state.items); const loading = useStore(state => state.loading); const error = useStore(state => state.error); const fetchItems = useStore(state => state.fetchItems); useEffect(() => { fetchItems(1); // 清理函数:effect销毁前中断未完成的请求 return () => { const currentController = useStore.getState()._fetchAbortController; currentController?.abort(); }; }, [fetchItems]); if (loading) return <div>Loading...</div>; if (error) return <div>{error}</div>; return ( <div> {items.map(item => <span key={item.id}>{item.name}</span>)} </div> ); }
这个写法在严格模式下的执行逻辑是:
- 组件首次挂载,第一个effect执行,发起第一次请求
- 严格模式触发effect销毁,执行清理函数中断第一次请求
- 第二个effect执行,发起正常业务请求,最终只有这一次有效请求会更新状态
完全不会出现重复请求、状态错乱的问题,同时这套逻辑在生产环境、非严格模式、组件快速重挂载、请求参数频繁变化的场景下都能正常工作,从根源上解决了请求竞态问题。
避坑提醒
不要用useRef存布尔标记位(比如存const mounted = useRef(false),判断mounted状态再发请求)的写法来规避双调用:这种写法只能挡住严格模式下的开发环境双调用,遇到组件正常重挂载、请求参数变化导致的effect重跑、慢请求覆盖新请求等场景,还是会出现竞态bug,属于治标不治本的歪门邪道。
可选优化方案(适合中大型项目)
如果项目里有大量列表请求、分页、缓存需求,可以直接用TanStack Query和Zustand配合,把请求去重、缓存、重试、取消、竞态处理的逻辑交给成熟库处理,不需要自己手动维护AbortController和请求状态,代码会更简洁。
内容的提问来源于stack exchange,提问作者otavio1992

