英文:
useReducer dispatch called in a wrong order
问题
我正在编写一个钩子,应该帮助我处理异步 promises。现在我遇到了一个非常奇怪的问题。基本上,我以特定顺序两次调用 dispatch,并使用两个动作("actionA" 和 "actionB")。一个动作("actionA")在 useEffect 清理回调中被调用。我期望它的工作方式是这样的:"state1" -> "actionA" -> "state2" -> "actionB" -> "state3"。但实际上我得到的是一些像这样的东西:"state1" -> "actionB" -> "unexpected state 4","state1" -> "actionA" -> "state2"。
我附上了这个问题的完整工作示例。点击按钮两次,我得到的日志如下:
reducer cancelled(10) start(8) pending(11)
dispatch cancel 8
dispatch reject 8
reducer pending(11) reject(8) rejected(15)
reducer pending(11) cancel(8) cancelled(17)
reducer cancelled(17) start(13) pending(18)
所以很明显 dispatch cancel 8
和 dispatch reject 8
的调用顺序是正确的。但是我期望的是获得类似 reducer pending(11) cancel(8) cancelled(17)
,cancelled(17) reject(8) cancelled(17)
这样的结果,但我得到的是这些 dispatches 以错误的顺序出现,它们作用于相同的状态!就好像它们在分叉状态一样。我不明白为什么会发生这种情况。组件本身没有卸载。在状态更改方面,useEffect 清理函数有什么特殊之处吗?
英文:
I'm writing a hook which is supposed to help me with async promises. Now I stumbled upon a very weird issue. Basically I call dispatch twice with two actions ("actionA" and "actionB") in a specific order. One action ("actionA") is called in a useEffect cleanup callback. I expect it to work like this: "state1" -> "actionA" -> "state2" -> "actionB" -> "state3". But instead I'm getting something like "state1" -> "actionB" -> "unexpected state 4", "state1" -> "actionA" -> "state2".
I attach full working example to this question. Click button twice. I'm getting logs like
reducer cancelled(10) start(8) pending(11)
dispatch cancel 8
dispatch reject 8
reducer pending(11) reject(8) rejected(15)
reducer pending(11) cancel(8) cancelled(17)
reducer cancelled(17) start(13) pending(18)
So it's clear that dispatch cancel 8
and dispatch reject 8
are called in a correct order. But instead of getting something like reducer pending(11) cancel(8) cancelled(17)
, cancelled(17) reject(8) cancelled(17)
like I expected, I'm getting those dispatches in a wrong order and they act on the same state! It's like they fork the state. I don't understand why is this happening. Component itself is not unmounting. Is there something special about useEffect cleanup function in regards to state change?
<!-- begin snippet: js hide: false console: true babel: false -->
<!-- language: lang-html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Test</title>
</head>
<body>
<div id="root"></div>
<script
crossorigin
src="https://unpkg.com/@babel/standalone@7/babel.js"
></script>
<script
crossorigin
src="https://unpkg.com/react@18/umd/react.development.js"
></script>
<script
crossorigin
src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
></script>
<script type="text/babel">
const { useEffect, useReducer } = React;
const { createRoot } = ReactDOM;
const rootElement = document.getElementById("root");
createRoot(rootElement).render(<App />);
function App() {
const [param, setParam] = React.useState(1);
const result = usePromise(
(signal) => {
return myFetch(param, signal);
},
[param]
);
const handleClick = () => {
setParam((param) => param + 1);
};
return (
<>
<div>param: {param}</div>
<div>result: {JSON.stringify(result)}</div>
<button onClick={handleClick}>click</button>
</>
);
}
function myFetch(param, signal) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
resolve(`result ${param}`);
}, 1000);
signal.addEventListener("abort", () => {
clearTimeout(timeoutId);
reject("aborted");
});
});
}
let nextId = 1;
function reducer(state, action) {
const id = nextId++;
let nextState = state;
switch (action.type) {
case "start":
if (state.status === "created" || state.status === "cancelled") {
nextState = {
id,
promiseId: action.promiseId,
status: "pending",
};
}
break;
case "resolve":
if (
state.status === "pending" &&
state.promiseId === action.promiseId
) {
nextState = { id, status: "fulfilled", value: action.value };
}
break;
case "reject":
if (
state.status === "pending" &&
state.promiseId === action.promiseId
) {
nextState = { id, status: "rejected", reason: action.reason };
}
break;
case "cancel":
nextState = { id, status: "cancelled" };
}
console.log(
"reducer",
`${state.status}(${state.id})`,
`${action.type}(${action.promiseId})`,
`${nextState.status}(${nextState.id})`
);
return nextState;
}
function usePromise(promiseFunction, deps) {
const [state, dispatch] = useReducer(reducer, {
id: nextId++,
status: "created",
});
useEffect(() => {
const promiseId = nextId++;
const abortController = new AbortController();
const promise = promiseFunction(abortController.signal);
dispatch({ type: "start", promiseId });
promise.then(
(value) => {
dispatch({ type: "resolve", promiseId, value });
},
(reason) => {
console.log("dispatch reject", promiseId);
dispatch({ type: "reject", promiseId, reason });
}
);
return () => {
console.log("dispatch cancel", promiseId);
dispatch({ type: "cancel", promiseId });
abortController.abort();
};
}, deps);
return state;
}
</script>
</body>
</html>
<!-- end snippet -->
答案1
得分: 1
这里的问题是 useEffect 的清理函数是异步调用的,在组件已经重新渲染并且第二个 dispatch 已经被调用之后。
我们可以在 usePromise 钩子中使用 useRef 来跟踪组件是否仍然挂载,并在清理函数中在调用第二个 dispatch 之前检查该值。
function usePromise(promiseFunction, deps) {
const [state, dispatch] = useReducer(reducer, {
id: nextId++,
status: "created",
});
const promiseRef = useRef(null);
useEffect(() => {
const promiseId = nextId++;
const abortController = new AbortController();
const promise = promiseFunction(abortController.signal);
promiseRef.current = promiseId;
dispatch({ type: "start", promiseId });
promise.then(
(value) => {
if (promiseRef.current === promiseId) {
dispatch({ type: "resolve", promiseId, value });
}
},
(reason) => {
if (promiseRef.current === promiseId) {
console.log("dispatch reject", promiseId);
dispatch({ type: "reject", promiseId, reason });
}
}
);
return () => {
console.log("dispatch cancel", promiseId);
if (promiseRef.current === promiseId) {
dispatch({ type: "cancel", promiseId });
}
abortController.abort();
};
}, deps);
return state;
}
英文:
the problem here is the useEffect cleanup function being called asynchronously, after the component has already re-rendered and the second dispatch has already been called.
We can use useRef on the usePromise hook to keep track of whether the component is still mounted, and check that value in the cleanup function before calling the second dispatch.
function usePromise(promiseFunction, deps) {
const [state, dispatch] = useReducer(reducer, {
id: nextId++,
status: "created",
});
const promiseRef = useRef(null);
useEffect(() => {
const promiseId = nextId++;
const abortController = new AbortController();
const promise = promiseFunction(abortController.signal);
promiseRef.current = promiseId;
dispatch({ type: "start", promiseId });
promise.then(
(value) => {
if (promiseRef.current === promiseId) {
dispatch({ type: "resolve", promiseId, value });
}
},
(reason) => {
if (promiseRef.current === promiseId) {
console.log("dispatch reject", promiseId);
dispatch({ type: "reject", promiseId, reason });
}
}
);
return () => {
console.log("dispatch cancel", promiseId);
if (promiseRef.current === promiseId) {
dispatch({ type: "cancel", promiseId });
}
abortController.abort();
};
}, deps);
return state;
}
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论