英文:
Parallel fetches capture the component state leading to race condition
问题
描述
我需要什么
我想要同时触发多个基于fetch
的Promise。每当这些Promise中的每一个解决时,值应该被处理并附加到组件状态(items
)。换句话说,我希望items
状态在每个Promise解决时都被更新,而不是在所有Promise解决时只更新一次。
问题
问题在于,如果我让每个Promise都有一个.then()
处理程序来处理自己的解决值,那么items
状态将被每个闭包捕获。这会导致第一个处理程序使用[...items, firstResultItem]
调用setState
,第二个处理程序使用[...items, secondResultItem]
,依此类推。每个闭包都无法看到其他处理程序对items
状态所做的更改。这种竞争条件导致了最后一个完成的请求会覆盖之前的请求,这是不希望发生的情况。
如果我将单个Promise处理程序应用于Promise.all(...)
,问题很容易解决,但这与所需的组件行为相悖。
想法
我考虑使用useRef
与useState
一起使用,或者找到一种方法来利用useEffect
,但我认为这是一个典型的问题,应该已经有一种惯用的解决方案。
代码
- 为了简化,使用
imitateFetch(promiseResolutionValue, delayInMs)
来模拟fetch。 items
具有单个项的默认状态;所有从imitateFetch
生成的新项应该附加到items
的当前状态。- 在CodeSandbox上查看可执行示例
import { useRef, 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;
});
setItems([...items, ...newItems]);
};
const handleTriggerFetch = () => {
// 有问题的变体。
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]);
// 这些可以工作,但更新只会在所有Promise都解决时发生一次。
// 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);
});
}
英文:
Description
What I Need
I want to trigger several fetch-based Promises in parallel. Whenever each of these promises resolves, the value should be processed and appended to component state (items
). In other words, I want the items
state to be updated with each promise resolution, rather then one time when all promises resolve.
Issue
The issue is that if I let each promise have a .then()
handler that processes own resolved value, then items
state is being captured in each closure. This leads to first handler calling setState
with [...items, firstResultItem]
, second -- with [...items, secondResultItem]
and so on. Each closure is not seeing the changes made to items
state from the other handler. This race condition results in latest-fetch-wins kind of situation, which is undesireable.
The issue is solved easily if I have a single promise handler applied to Promise.all(...)
, but this goes against the desired component behavior.
Thoughts
I was thinking about using useRef
together with useState
or find a way to leverage useEffect
, but I think this is a typical problem that must already have an idiomatic solution.
Code
- For simplicity, the fetches are faked with
imitateFetch(promiseResolutionValue, delayInMs)
. items
has a default state of a single item; all the new items produced fromimitateFetch
should be appended to current state ofitems
.- See Executable example on codesandbox
import { useRef, 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;
});
setItems([...items, ...newItems]);
};
const handleTriggerFetch = () => {
// Buggy variant.
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]);
// These works, but the update happens once, when ALL promises resolve.
// 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}>
Imitate parallel fetches
</button>
</div>
);
}
function imitateFetch<T>(valueToResolve: T, ms = 1000): Promise<T> {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(valueToResolve);
}, ms);
});
}
答案1
得分: 1
除非我误解了你的问题,解决方案实际上很简单。您可以将一个更新函数传递给您的 setItems
函数,该函数以先前的状态作为参数,并返回新状态。更新函数 以挂起状态为基础计算下一个状态,因此可以消除竞态条件。
setItems((i: Item[]) => [...i, ...newItems]);
英文:
Unless I misunderstand your question, the solution is actually simple. You can pass an updater function to your setItems
function, which takes the previous state as an argument, and returns the new state. The updater function takes the pending state and calculates the next state from it, so it would eliminate race conditions.
setItems((i: Item[]) => [...i, ...newItems]);
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论