Dan Abramov, in response to a React developer asking why Suspense was not responding to the fetch API.

From the legend Dan Abramov himself, we receive such gems as “There is [no data fetching solution compatible with React Suspense] that exists yet,” and “[React Cache] will be the first one,” and “Suspense is limited to code splitting.”

If I have one thing to tell Daniel “Abra Cadabra” Abramov, besides how impressed I am with his work, it’s this:

“No.”

Let’s reveal the magic behind the curtain that is React Suspense. For educational purposes, I’ll cover how I created this package.

Shut Up and Give Me the Package! 💰

If you’re just here for solutions, I don’t blame you. You can find fetch-suspense on NPM and the most extensive documentation of your life on the GitHub repository.

How Does Suspense Work? 🔮

A lot of the new React features are built into the React library, as opposed to being external packages, due to the performance benefits of being tightly coupled to the engine that powers React, known as React Fiber.

Due to React Fiber’s direct integration with features such as Suspense and hooks, you cannot create a verbatim copy of Suspense in React 16.5. However, you can probably make a less performant polyfill. I’ll use some polyfill examples so that you can conceptualize what is happening with Suspense.

Here is ye olde class component: a fossil remnant of yonder days of React development. The componentDidCatch method is a method that fires whenever a child component throws an error. This allows you to replace uncaught JavaScript errors with a nice UI for your users or otherwise implement important logic during application errors.

What the above does is mounts Suspense. Since there is no error in the local state, the children of Suspense are mounted too. In this case, the <ErrorThrower /> component is mounted, and it throws an error.

That error bubbles up to the Suspense instance, where the componentDidCatch method receives it. It handles that error by saving it to its state, causing it to re-render.

Now that it has rendered with an error in its local state, it no longer renders its children prop, nor the <ErrorThrower /> devil-child as a result. Instead, it renders its fallback prop, which we have set to a nice <Loading /> modal.

This is how Suspense works now, except instead of throwing errors, JavaScript Promises are thrown. When Suspense catches a Promise, it re-renders, displaying the fallback prop instead of the children that previous threw a Promise. When the Promise resolves, it re-renders again; this time no longer displaying the fallback prop, and instead attempting to re-render the original children, under the presumption that the children are now ready to be rendered without throwing Promises around like they’re meaningless.

An implementation may look something like this:

It’s important to note here that the original children attempted to render before the fallback occurred. It never succeeded.

How Does This Apply to Fetch Hooks? 🎣

What you should have gathered by now is that the fetch hook will need to throw Promises. So it does. That promise is conveniently the fetch request. When Suspense receives that thrown fetch request, it falls back to rendering its fallback prop. When that fetch request completes, it attempts to render the component again.

There’s just one little tricky dicky problem with that — the component that threw the fetch request had only attempted to render, but did not succeed. In fact, it is not a part of the fallback at all! It has no instance. It never mounted. It has no state (not even a React hook state); it has no component lifecycle or effects. So when it attempts to render again, how does it know the response of this fetch request? Suspense isn’t passing it, and it — not being instantiated — cannot have data attached to it.

Golly, How Do You Solve That Conundrum? 🤔

We solve it with memoization!

“Like that fancy new React.memo feature?”

“Yes!” (in concept)

“No!” (more literally)

It does not use React.memo , which memoizes React components based on their props. Instead, I use an array of infinite depth to memoize the parameters passed to fetch.

If a request comes in to fetch data that has been requested before (the second attempt to instantiate after the first attempt failed with a Promise), then it simply returns the data that eventually resolved from the first request’s Promise. If this is a fresh request, then we fetch it, cache it in the memoization array, and throw the fetch Promise. By comparing the current request to all entries in the memoization array, we know if we have dispatched this request before.

That Sounds Like a Memory Leak 💧

It can be a feature or a bug!

But if you think it’s a bug in your project, you can invalidate the cache by providing a lifespan in milliseconds to the fetch request. Passing a third parameter (a number) to the useFetch hook will tell it to remove the metadata from the memoization array after that many milliseconds. We implement it as easily as so:

When the fetch has completed, and we’ve updated the metadata, tick-tock. It’s important that the lifespan timer occurs after the catch of the Promise, because we want it to set even if an error occurred.

Conclusion 🍬

When Dan Abramov tells you that you can’t do something, you do it.

If you liked this article, feel free to give it a clap or two. It’s quick, it’s easy, and it’s free! If you have any questions or relevant great advice, please leave them in the comments below.

To read more of my columns, you may follow me on LinkedIn and Twitter, or check out my portfolio on CharlesStover.com.