In an attempt to understand react's <Suspense>
component, I'm trying to utilize setTimeout
to write a simple hook to trigger a "suspended" state for some amount of time before rendering.
In my testing I'm using the following setup:
import { Suspense, useMemo, useState } from "react";
function useSuspend(ms: number) {
const [isSuspended, setSuspended] = useState(true);
const promise = useMemo(
() =>
new Promise<void>((resolve) => {
setTimeout(() => {
setSuspended(false);
resolve();
}, ms);
}),
[ms]
);
console.table({ isSuspended, ms });
if (isSuspended) {
throw promise;
}
}
function Suspending() {
const [id] = useState(Math.random().toFixed(20));
console.log(id);
useSuspend(2500);
return "Done";
}
export default function Main() {
return (
<Suspense fallback={"Loading..."}>
<Suspending />
</Suspense>
);
}
However, this produces some (to me) rather unexpected log prints:
0.91830134558829579206
┌─────────────┬────────┐
│ (index) │ Values │
├─────────────┼────────┤
│ isSuspended │ true │
│ ms │ 2500 │
└─────────────┴────────┘
(2.5s pause)
0.33716150767738661820
┌─────────────┬────────┐
│ (index) │ Values │
├─────────────┼────────┤
│ isSuspended │ true │
│ ms │ 2500 │
└─────────────┴────────┘
(continues infinitely every 2.5s forever)
The text "Done" is never rendered either.
These logs would seem to indicate that the <Suspending />
component does not retain its state after the useSuspend
hook completes, prompting the component to render "as if new", which is counter intuitive to me. Could someone please explain this behavior?
These logs would seem to indicate that the
<Suspending />
component does not retain its state after the useSuspend hook completes, prompting the component to render "as if new", which is counter intuitive to me.
That's correct. Suspense is designed so that when it catches a promise it will unmount the child tree and render the fallback (if any) instead. Later, the promise resolves and suspense will once again mount the children. As is typical for newly mounted components, their states will be assigned their initial values (ie, isSuspended
is true
), and the useMemo
runs and creates a new promise. This promise then gets thrown, and the process repeats
Throwing promises for suspense to catch is kinda tricky. You typically need to have some value that exists outside the component which you can synchronously check to see if the asynchronous work has been done. If it hasn't, you then throw a promise which can set that external value and resolve itself. For example:
let loaded = false;
let promise = null;
function useSuspend(ms: number) {
if (!loaded) {
if (!promise) {
promise = new Promise(resolve => {
setTimeout(() => {
loaded = true;
resolve();
}, ms);
});
}
throw promise;
}
}
Note that since loaded
is a global variable, every component in your app is sharing it. Whichever component calls useSuspend
first will start a timeout, and all others will reuse the same promise. If you want different components to have different timeouts, you'll need to create some more complex store of values which match your needs.