React 18 - infinite loop and function as child issue with transition and suspense with useMemo
See original GitHub issueI’ve turned off strict mode, and tried to create a simple example that breaks in v18 rc3.
I’m trying to use useMemo
to detect when some state changes and create a new memoized promise. Another useMemo
call detects when the promise changes and wraps it in a “resource” object. I am passing the resource down to a child. There are multiple suspense boundaries between where I am creating the “resource” and where I’m calling .read()
. I expect this to load once with no errors, and when I click the button I expect a single transition. Instead, although the app loads I get this error react-dom.development.js:86 Warning: Functions are not valid as a React child. This may happen if you return a Component instead of <Component /> from render. Or maybe you meant to call this function
. When I click the button, it then goes into an infinite loop. This is unexpected because <App>
should not be suspending, only <Child>
. I’m also not returning any components without calling them from what I can tell.
If I set the “resource” into state it works fine. This only seems to happen if I try to create the “resource” with useMemo
.
Minimal reproduction case
import {
startTransition,
Suspense,
useRef,
useEffect,
useState,
useMemo,
} from "react";
import logo from "./logo.svg";
import "./App.css";
function App() {
const ref = useRef(0);
const [validQueryParams, setValidQueryParams] = useState();
const promise = useMemo(() => {
console.log("making promise that resolves in 3s", ref.current);
ref.current++;
return new Promise((res) => {
setTimeout(() => {
console.log(validQueryParams);
return res();
}, 3000);
});
}, [validQueryParams]);
const query = useMemo(() => {
console.log("wrapping promise in resource object");
return wrapPromise(promise);
}, [promise]);
useEffect(() => {
console.log("mount");
return () => console.log("unmount");
}, []);
console.log({ query });
return (
<>
<Suspense fallback={() => <div>fallback</div>}>
<Child query={query} />
<button
onClick={() => {
startTransition(() => {
setValidQueryParams(Math.random());
});
}}
>
Start transition
</button>
</Suspense>
</>
);
}
function Child({ query }) {
if (!query) return null;
return (
<Suspense fallback={() => <div>fallback</div>}>
<div>{JSON.stringify(query.read())}</div>
</Suspense>
);
}
export default App;
export function wrapPromise(promise) {
let status = "pending";
let result;
const suspender = promise.then(
(r) => {
status = "success";
result = r;
},
(e) => {
status = "error";
result = e;
}
);
return {
read() {
if (status === "pending") {
throw suspender;
} else if (status === "error") {
throw result;
} else if (status === "success") {
return result;
}
},
};
}
Issue Analytics
- State:
- Created a year ago
- Comments:9 (5 by maintainers)
Here is a smaller example of the same loop:
https://codesandbox.io/s/serverless-wave-469uso?file=/src/App.js
The issue here is that when you suspend and React doesn’t have anything useful to commit (because you’re in a transition, so there is no fallback loading state to show), the whole result of that render gets thrown away. This includes anything that happened during render, no matter how high in the tree. This includes any new memoized results. So
useMemo
— even in the parent component — doesn’t get a chance to actually “get saved”. It’s like thisuseMemo
never ran.This can work in a limited way, but any information you rely on has to be calculated outside of render. This is why storing a resource in state works (it won’t get dropped) but creating a resource during render doesn’t (it will get dropped).
This is your original example:
https://codesandbox.io/s/dazzling-cherry-5bsiv6?file=/src/App.js
I can reproduce this error:
Like the error says, the issue is that you are trying to render a function:
This line should be instead:
This is the version where this mistake is fixed (in both places where the original code had it):
https://codesandbox.io/s/vigorous-cloud-clwtvt?file=/src/App.js
I can observe that after clicking “Start transition”, there is a log that repeats every three seconds. I assume this is the issue you are referring to as the loop. I’ll need to look a bit more to understand why this doesn’t work. However, in general, Suspense relies on having an external cache. If you’re creating “resources” during render (whether with useMemo or useState), you will likely run into issues.