Using Suspense with React: without a 3rd party library
Tags: react, webdev, javascript, development
Published: 2024-01-19 12:11:21

## The basics


Some time ago, React came out with `Suspense` and has changed the way data fetching is done in React-powered applications.

From a **fetch-then-render** or a **fetch-on-render** strategy, we are moving to a **render-as-you-fetch** strategy. 
There are [multiple blogs](https://blog.logrocket.com/data-fetching-react-suspense/#react-data-fetching-patterns) explaining the difference between the three but here is a quick TLDR:

1. **fetch-on-render** - Most common. This is where we let a component render (a loading UI) and in effect, we make our call to fetch the data.

2. **fetch-then-render** - We fetch all the data first and then render the component sub-tree. Again we use an effect here but try to render at least the children after their data is already loaded in the parent.

3. **fetch-as-you-render** - Used along with `React.Suspense`, the data call is made while the component is being rendered. It is not inside an effect. While the data is being loaded the component is in a suspended state and `React.Suspense` is used to show a fallback UI.


## How to use it?

According to the React docs, ideally, a developer should not have to interact with the Suspense API themself. The developer should make use of certain libraries that the React team recommends and that are compatible with the Suspense framework.

From the docs:

> Suspense-enabled data fetching without the use of an opinionated framework is not yet supported. The requirements for implementing a Suspense-enabled data source are unstable and undocumented. An official API for integrating data sources with Suspense will be released in a future version of React.

A few examples are React-Query, Next.Js. SWR.

If we use something like React Query inside a Suspense tree we should be able to take advantage of Suspense features.


## A working demo: To throw a promise

There are only a few docs to help with working with Suspense directly. I tried creating a working example using my basic understanding of what might be happening under the hood.

Suspense does not detect when data is fetched inside an Effect or event handler. One of the major ideas of working with Suspense is that data fetching should not be done in effect, but while rendering itself. This means data should be fetched inside the body of the React component.

So ideally, this is how fetching data might look like:

```
import React, { useEffect, useState, Suspense } from "react";

const Todo = ({ id }) => {
  const data = fetchSomeData(
    `https://jsonplaceholder.typicode.com/todos/${id}?_delay=2000`
  );
  return <p>{data.title}</p>;
};

function App() {
  return (
    <div>
      <Suspense fallback={<p>...loading</p>}>
        <Todo id={1} />
        <Todo id={2} />
        <Todo id={3} />
      </Suspense>
    </div>
  );
}

export default App;
```


A glance at the code tells us that `fetchSomeData` should return our fetched data. But it also tells us that the above code will fail because data will be filled asynchronously and `data.title` will throw a **ReferenceError**.

This is where `Suspense` comes into the picture. Our component subtree should let Suspense know it is in a Suspended state and that the fallback UI: `<p>...loading</p>` should be shown.

To check this out, one can throw a promise and the UI will Suspend. Here is a [demo](https://codesandbox.io/p/sandbox/react-suspense-demo-forked-9tvnfz?layout=%257B%2522sidebarPanel%2522%253A%2522EXPLORER%2522%252C%2522rootPanelGroup%2522%253A%257B%2522direction%2522%253A%2522horizontal%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522id%2522%253A%2522ROOT_LAYOUT%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522clrj2fuas00063b6iv7xgtj8b%2522%252C%2522sizes%2522%253A%255B70%252C30%255D%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522EDITOR%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522id%2522%253A%2522clrj2fuas00023b6i5uf7c8bw%2522%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522SHELLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522id%2522%253A%2522clrj2fuas00033b6inp5ztdar%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522DEVTOOLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522id%2522%253A%2522clrj2fuas00053b6i8perxjze%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%252C%2522sizes%2522%253A%255B50%252C50%255D%257D%252C%2522tabbedPanels%2522%253A%257B%2522clrj2fuas00023b6i5uf7c8bw%2522%253A%257B%2522id%2522%253A%2522clrj2fuas00023b6i5uf7c8bw%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clrj91dbc01q13b6it1xvg4fb%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522FILE%2522%252C%2522initialSelections%2522%253A%255B%257B%2522startLineNumber%2522%253A46%252C%2522startColumn%2522%253A27%252C%2522endLineNumber%2522%253A46%252C%2522endColumn%2522%253A27%257D%255D%252C%2522filepath%2522%253A%2522%252Fsrc%252FApp.js%2522%252C%2522state%2522%253A%2522IDLE%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clrj91dbc01q13b6it1xvg4fb%2522%257D%252C%2522clrj2fuas00053b6i8perxjze%2522%253A%257B%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clrj2fuas00043b6ieo9in2ty%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522UNASSIGNED_PORT%2522%252C%2522port%2522%253A0%252C%2522path%2522%253A%2522%252F%2522%257D%255D%252C%2522id%2522%253A%2522clrj2fuas00053b6i8perxjze%2522%252C%2522activeTabId%2522%253A%2522clrj2fuas00043b6ieo9in2ty%2522%257D%252C%2522clrj2fuas00033b6inp5ztdar%2522%253A%257B%2522tabs%2522%253A%255B%255D%252C%2522id%2522%253A%2522clrj2fuas00033b6inp5ztdar%2522%257D%257D%252C%2522showDevtools%2522%253Atrue%252C%2522showShells%2522%253Atrue%252C%2522showSidebar%2522%253Atrue%252C%2522sidebarPanelSize%2522%253A15%257D) for the same.

Now since there is no wrapping Suspense boundary, there was no fallback handling and the app crashed.

Along similar lines, we can get our Suspense to work by creating `fetchSomeData` to work so that it throws a promise while it is not ready (indicating that it is in a suspended state).

Our basic structure should look like the below (Don't use it):

```
function fetchWithSuspense(url) {
  const promise = fetch(url)
    .then((response) => {
      return response.json();
    })
    .then((data) => {
      return data;
    });

  throw promise;
}

const Todo = ({ id }) => {
  const data = fetchWithSuspense(
    `https://jsonplaceholder.typicode.com/todos/${id}?_delay=2000`
  );

  return <p>{data.title}</p>;
};
```

The problem with the above is that it will trigger a million network requests as we are never returning data correctly. We are only throwing our promise and because this time we have a Suspense boundary enabled our component is being rendered again. This calls `fetchWithSuspense` again and the cycle goes on.


## Preventing unnecessary calls

To prevent the above, we can store the result of our network calls when the promise is fulfilled. This helps us return early from `fetchWithSuspense` once we have the data.

Our final method definition looks like this:

```
let cacheMap = {};
function fetchWithSuspense(url) {
  if (cacheMap[url]) return cacheMap[url];

  const promise = fetch(url)
    .then((response) => {
      return response.json();
    })
    .then((data) => {
      cacheMap = { ...cacheMap, [url]: data };
      return data;
    });

  throw promise;
}
```

We created a simple cache map above and then used it to return the data once it is fetched and parsed correctly.

Putting the above all together we have something like:

```
import React, { useEffect, useState, Suspense } from "react";

let cacheMap = {};
function fetchWithSuspense(url) {
  if (cacheMap[url]) return cacheMap[url];

  const promise = fetch(url)
    .then((response) => {
      return response.json();
    })
    .then((data) => {
      cacheMap = { ...cacheMap, [url]: data };
      return data;
    });

  throw promise;
}

const Todo = ({ id }) => {
  const data = fetchWithSuspense(
    `https://jsonplaceholder.typicode.com/todos/${id}?_delay=2000`
  );

  return <p>{data.title}</p>;
};

function App() {
  return (
    <div>
      <Suspense fallback={<p>...loading</p>}>
        <Todo id={1} />
        <Todo id={2} />
        <Todo id={3} />
      </Suspense>
    </div>
  );
}

export default App;
```

Here is a working [demo](https://codesandbox.io/p/sandbox/react-suspense-demo-forked-r6ts8y?file=%2Fsrc%2FApp.js%3A107%2C36&layout=%257B%2522sidebarPanel%2522%253A%2522EXPLORER%2522%252C%2522rootPanelGroup%2522%253A%257B%2522direction%2522%253A%2522horizontal%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522id%2522%253A%2522ROOT_LAYOUT%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522UNKNOWN%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522clrj2fuas00063b6iv7xgtj8b%2522%252C%2522sizes%2522%253A%255B70%252C30%255D%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522EDITOR%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522EDITOR%2522%252C%2522id%2522%253A%2522clrj2fuas00023b6i5uf7c8bw%2522%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522direction%2522%253A%2522horizontal%2522%252C%2522id%2522%253A%2522SHELLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522SHELLS%2522%252C%2522id%2522%253A%2522clrj2fuas00033b6inp5ztdar%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%257D%252C%257B%2522type%2522%253A%2522PANEL_GROUP%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522direction%2522%253A%2522vertical%2522%252C%2522id%2522%253A%2522DEVTOOLS%2522%252C%2522panels%2522%253A%255B%257B%2522type%2522%253A%2522PANEL%2522%252C%2522contentType%2522%253A%2522DEVTOOLS%2522%252C%2522id%2522%253A%2522clrj2fuas00053b6i8perxjze%2522%257D%255D%252C%2522sizes%2522%253A%255B100%255D%257D%255D%252C%2522sizes%2522%253A%255B50%252C50%255D%257D%252C%2522tabbedPanels%2522%253A%257B%2522clrj2fuas00023b6i5uf7c8bw%2522%253A%257B%2522id%2522%253A%2522clrj2fuas00023b6i5uf7c8bw%2522%252C%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clrj91dbc01q13b6it1xvg4fb%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522FILE%2522%252C%2522initialSelections%2522%253A%255B%257B%2522startLineNumber%2522%253A107%252C%2522startColumn%2522%253A36%252C%2522endLineNumber%2522%253A107%252C%2522endColumn%2522%253A36%257D%255D%252C%2522filepath%2522%253A%2522%252Fsrc%252FApp.js%2522%252C%2522state%2522%253A%2522IDLE%2522%257D%255D%252C%2522activeTabId%2522%253A%2522clrj91dbc01q13b6it1xvg4fb%2522%257D%252C%2522clrj2fuas00053b6i8perxjze%2522%253A%257B%2522tabs%2522%253A%255B%257B%2522id%2522%253A%2522clrj2fuas00043b6ieo9in2ty%2522%252C%2522mode%2522%253A%2522permanent%2522%252C%2522type%2522%253A%2522UNASSIGNED_PORT%2522%252C%2522port%2522%253A0%252C%2522path%2522%253A%2522%252F%2522%257D%255D%252C%2522id%2522%253A%2522clrj2fuas00053b6i8perxjze%2522%252C%2522activeTabId%2522%253A%2522clrj2fuas00043b6ieo9in2ty%2522%257D%252C%2522clrj2fuas00033b6inp5ztdar%2522%253A%257B%2522tabs%2522%253A%255B%255D%252C%2522id%2522%253A%2522clrj2fuas00033b6inp5ztdar%2522%257D%257D%252C%2522showDevtools%2522%253Atrue%252C%2522showShells%2522%253Atrue%252C%2522showSidebar%2522%253Atrue%252C%2522sidebarPanelSize%2522%253A15%257D)

Notice how we see the fallback `...loading` first and then the three titles. All this without a million network calls.


## Summary

Above, we saw a small demo of how suspense-enabled libraries **might be working** under the hood. Reality might be a lot different and the internals are much more complex and handle a lot more cases than my simple demo. Nonetheless, I hope the blog post gave you a basic idea of how libraries like `SWR`, `react-query`, and event hooks like [`use`](https://react.dev/reference/react/use#use) might be integrating with `React.Suspense` under the hood.