reactjsreact-statereact-lifecyclereact-suspense

Why does this `<Suspense>` child component lose its state rather than render after the suspending promise resolves?


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?


Solution

  • 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.