并行Fetch捕获组件状态引发竞态条件,求惯用解决方案
需求
希望并行触发多个基于Fetch的Promise,每个Promise resolve时立即处理返回值并追加到组件状态items中,即每次Promise完成都单独更新状态,而非等待所有Promise结束后一次性更新。
问题
如果给每个Promise单独添加.then()处理返回值,items状态会被每个闭包捕获。第一个处理函数调用setState时用的是[...items, firstResultItem],第二个用的是[...items, secondResultItem]——每个闭包无法感知其他处理函数对items的修改,最终会出现“最晚请求覆盖之前结果”的竞态条件,不符合预期。
使用Promise.all(...)的单个处理函数能避免竞态,但这会导致所有结果一次性更新,违背了实时更新的需求。
思路
曾考虑结合useRef与useState,或利用useEffect,但认为这是典型问题,应该有更简洁的惯用解决方案。
- 用
imitateFetch(promiseResolutionValue, delayInMs)模拟Fetch请求 items默认包含一个条目,所有imitateFetch生成的新条目需要追加到当前items状态中
import { useState } from "react"; export interface Item { id: string; text: string; } export function App() { const [items, setItems] = useState<Item[]>([ { id: new Date().toISOString(), text: new Date().toLocaleTimeString() }, ]); const naiveUpdateItemListWith = (newItemTexts: string[]) => { const newItems = newItemTexts.map(newItemText => { const item: Item = { id: new Date().toISOString(), text: newItemText, }; return item; }); // 这里使用闭包捕获的旧items,导致竞态 setItems([...items, ...newItems]); }; const handleTriggerFetch = () => { // 有问题的写法:每个then里的更新依赖闭包中的旧状态 const one = imitateFetch('one', 1000).then(text => naiveUpdateItemListWith([text])); const two = imitateFetch('two', 1500).then(text => naiveUpdateItemListWith([text])); const three = imitateFetch('three', 2000).then(text => naiveUpdateItemListWith([text])); Promise.all([one, two, three]); // 可行但不符合实时更新需求的写法:所有请求完成后一次性更新 // const one = imitateFetch('one', 1000); // const two = imitateFetch('two', 1500); // const three = imitateFetch('three', 2000); // Promise.all([one, two, three]).then(texts => naiveUpdateItemListWith(texts)); }; return ( <div> {items.map(item => <div key={item.id}>[{item.id}] -- {item.text}</div>)} <button onClick={handleTriggerFetch}> 模拟并行请求 </button> </div> ); } function imitateFetch<T>(valueToResolve: T, ms = 1000): Promise<T> { return new Promise((resolve, reject) => { setTimeout(() => { resolve(valueToResolve); }, ms); }); }
核心是使用React状态的函数式更新,让每次状态更新都基于最新的状态快照,而非闭包捕获的旧值。
修改naiveUpdateItemListWith函数,将setItems的参数改为函数形式:
const updateItemListWith = (newItemTexts: string[]) => { const newItems = newItemTexts.map(newItemText => { const item: Item = { id: new Date().toISOString(), text: newItemText, }; return item; }); // 函数式更新:prevItems是当前最新的状态 setItems(prevItems => [...prevItems, ...newItems]); };
然后在handleTriggerFetch中使用这个修改后的函数:
const handleTriggerFetch = () => { imitateFetch('one', 1000).then(text => updateItemListWith([text])); imitateFetch('two', 1500).then(text => updateItemListWith([text])); imitateFetch('three', 2000).then(text => updateItemListWith([text])); };
这样每个Promise resolve时,都会基于当前最新的items状态追加新条目,彻底避免竞态条件,同时满足“实时更新”的需求。
原理
React的setState支持传入函数,函数的参数是当前最新的状态值(prevState),返回值是新的状态。这种方式不会受闭包捕获旧值的影响,因为每次调用时都会获取到最新的状态快照,确保更新逻辑的正确性。
内容的提问来源于stack exchange,提问作者Igor Soloydenko

