React 18 improves the existing behavior of Suspense

The release of React 18 Alpha has brought a lot of excitement amongst the React community. It is more focused on user experience, architecture changes, and adaptation of concurrent features.

One of the features in React 18 everyone is talking about is Suspense. It is a vast topic to discuss as many changes have been introduced related to Suspense. In this blog, we will look into the improvements coming up in React 18 related to Suspense.

What is Suspense?

As the name implies, it suspends something until it is ready.

React 16.6 added a <Suspense> component that lets us ‘wait’ for some code to load and specify a loader while we are waiting for the code to finish loading.

The React team added basic support for Suspense in React 16, which missed a lot of planned features. A full suite of Suspense functionality that depends on Concurrent React was added in React 18.

In the context of migration, the version of Suspense that exists in 16 and 17 is referred to as ‘Legacy Suspense’ while that in React 18 is referred to as ‘Concurrent Suspense’. The feature itself is still called just “Suspense”.

Let’s look into the behavioral changes in Suspense before and after React 18.

Before

Consider we are building our blog in React. We wrap <BlogPost> and <Share> components by Suspense similar to an example mentioned in the discussion.
React will display the ‘Loading blog…’ until the blog post is fetched.

//App.jsx

import { useEffect, useState, Suspense } from "react";
import { fetchBlogData } from "./fakeApi";
import { faTwitter, faReddit } from '@fortawesome/free-brands-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';

const blog = fetchBlogData(1);

function App() {
  const [post, setPost] = useState(blog);
  return (
    <>
      <Suspense
        fallback={
          <><h1>Loading blog...</h1></>
        }
      >
        <BlogPost post={post} />
        <Share id={1} />
      </Suspense>
    </>
  );
}

function Share({ id }) {
  useEffect(() => {
    console.log("Effect Share");

    return () => {
      console.log("Cleanup Share");
    };
  });

  console.log("Render Share");
  return (
    <div>Share:&nbsp;
      <span> <FontAwesomeIcon icon={faTwitter} /></span>
      <span> <FontAwesomeIcon icon={faReddit} /></span>
    </div>
  )
}

function BlogPost({ post }) {
  const blog = post.details.read();

  useEffect(() => {
    console.log("Effect BlogPost");
    return () => {
      console.log("Cleanup BlogPost");
    };
  });

  return (
    <div>
      <h1>Blog Post</h1>
      <h3>{blog.title}</h3>
      <span>{blog.body}</span>
    </div>
  );
}

export default App;

//fakeAPi.js

export function fetchBlogData() {
  let postsPromise = fetchPosts(1);
  return {
    details: wrapPromise(postsPromise)
  };
}
function wrapPromise(promise) {
  let status = "pending";
  let result;
  let 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;
      }
    }
  };
}

let blogPosts = [
  {
    id: 1,
    title: 'qui est esse',
    body: 'est rerum tempore vitae\nsequi sint nihil reprehenderit'
  },
];

function fetchPosts(id) {
  let post = blogPosts[id];
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("fetched blogs");
      resolve(post);
    }, 2000);
  });
}

On running the application, we see the below output in the browser console -

Render Share
Effect Share
fetched blogs
Effect BlogPost

We see that the Share(sibling) component immediately mounts to the DOM and its effect is fired without waiting for the blog post to be fetched.

React skips over the BlogPost component and proceeds to render the Share component, committing whatever possible. But we do not see the Share component immediately instead, we see ‘Loading blog..’ text in the UI. It is because before the browser is allowed to paint, React shows the fallback UI and hides everything inside Suspense with display: hidden till the blog is fetched. Although the DOM is in an inconsistent state, the user does not see any inconsistency in UI due to this trick.

But this behavior causes some issues when the child component needs to read from the DOM layout to correctly display its elements.

After

Running the previous example in React 18 with createRoot gives the below output in the console.

Render Share
fetched blogs
Render Share
Effect BlogPost
Effect Share

With the changes in React 18 with createRoot React interrupts the Share component(sibling) and prevent it from committing, thereby delaying effects from firing until the blog post is fetched.

The whole tree is committed simultaneously in a single, consistent batch after the data is fetched.

The Impact of the change on the timings of refs in Suspense boundary

To understand the impact on refs, let’s modify the previously discussed example with a button having ref.

function App() {
  const [post, setPost] = useState(blog);
  const buttonRef = useRef(null);
  return (
    <>
      <Suspense
        fallback={
          <><h1>Loading blog...</h1></>
        }
      >
        <BlogPost post={post} />
        <Share id={1} />
        <button ref={buttonRef} />
      </Suspense>
    </>
  );
}

In Legacy Suspense, buttonRef.current will point to a DOM node immediately on the initial render irrespective of the data fetched. Whereas, in Concurrent Suspense, buttonRef.current will be null until BlogPost resolves and the Suspense boundary is unblocked. Any code in an ‘App’ component that accesses buttonRef will observe a change in the timing of when the ref resolves.

To summarize, React’s new behavior in Suspense is now consistent with the rest of React’s rendering model.

To know more about the Suspense behavioral changes, check out the WG discussion.

Need help on your Ruby on Rails or React project?

Join Our Newsletter