Before Ask, I already:
- I understand
StrictMode
will make render -> unmount -> render, which will make my component renders twice - I understand in
StrictMode
I need to use keep my componentpure function
to make it work - Most related to my question is this. It's exactly the same problem I'm facing, but no answers I expected.
The Problem:
My function will make a network call with abortController.signal
as useState
. Upon unmounting, it'll abort the controller by useEffect
.
But, when react render(0) -> unmount(0) -> render(1), the umount(0) actually read the state
already from render(1)
, which lead to my render(1)
got cleaned up, leading to unexpected result.
My expectation:
React's first umount(0)
will see state from render(0)
so my cleanup function works as expected.
Code:
import { Suspense, useEffect, useMemo, useState } from 'react';
// make promise suspendable
enum Status {
Pending,
Fulfilled,
Rejected,
}
const Suspendable = <T,>(promise:Promise<T>): (() => T) => {
let status: Status = Status.Pending;
let error: Error;
let result: T;
const suspender = promise.then((r: T): void => {
status = Status.Fulfilled;
result = r;
})
.catch(err => {
status = Status.Rejected;
error = err;
});
return () => {
switch(status) {
case Status.Pending:
throw suspender;
case Status.Rejected:
throw error;
case Status.Fulfilled:
return result;
default:
throw Error(`Unexpected arg ${status}`);
}
}
};
// make fetch a promise
const Req = (uri: string, config: RequestInit={}): Promise<string> => new Promise<string>((resolve, reject) => {
fetch(uri, config)
.then(resp => resp.text())
.then(resolve)
.catch(reject);
// using this catch lead to page stuck at loading forever as promise never resolved
// .catch(e => {
// if(e.name === "AbortError") {
// return;
// }
// return reject(e);
// });
});
// do actual render content
const Renderer = ({ getResponse }: { getResponse: () => string }) => {
console.log(`Renderer start`);
const response = getResponse();
console.log(`Renderer get response, return component`);
return <pre>{response}</pre>;
}
// state interface
interface Result {
suspendable: () => string;
abortController: AbortController;
retryKey: number;
}
const App = () => {
const [controller, setResult] = useState<Result>((() => {
const abortController = new AbortController();
const retryKey = Math.floor(Math.random() * 100000);
console.log(`create controller ${retryKey}`)
return {
abortController,
retryKey,
suspendable: Suspendable<string>(Req(`/`, { signal: abortController.signal }))
};
})());
useEffect(() => {
return () => {
console.log(`clean up ${controller.retryKey}`);
controller.abortController.abort();
}
})
// ErrorBoundary wont help (
return <>
{/* <ErrorBoundary> */}
<Suspense fallback={<p>Loading</p>}>
<Renderer getResponse={controller.suspendable} />
</Suspense>
<p>-- Parent Node</p>
{/* </ErrorBoundary> */}
</>;
}
export default App;
Expectation:
StrictMode
runs the first time, page error out, or catch by errorBoundaryStrictMode
unmount,useEffect
runs cleanup, connection abortedStrictMode
runs a second time, startsSuspense
loading, and renders page content afterfetch
finished
Unexpected, actual Result:
StrictMode
clean up my second state on the first render, making my second render aborted on the loading process.
Unexpceted logs:
create controller 8343 # first render
create controller 69996 # second render? no clean up called?
Renderer start
clean up 69996 # first unmount finally happend? but cleanup my second render?
Renderer start
Uncaught DOMException: The operation was aborted. # expected
Renderer start
Uncaught DOMException: The operation was aborted. # unexpected
clean up 69996 # again?
Version:
"react": "^18.2.0"
Edit:
the code from this works as:
const hasUnmounted = useRef(false)
useEffect(() => {
return () => {
if(hasUnmounted.current){
// do my cleanup
}
hasUnmounted.current = true;
}
})
But,
- I don't understand why my old code can't work
- Why I need to manually check using
ref
.