Making Promises Suspendable

grubba

Gabriel Grubba

Posted on March 20, 2023

Making Promises Suspendable

Making Promises Suspendable

This is a two-part article in which we focus first on understanding the foundations of the reasons to use the Suspense API, and in the next part, we will discuss how to use the New Suspense hooks for Meteor

I will explain how to make your promises go into suspense mode and what it takes to get there.

I recommend two articles for theory on this side that helped me understand how React deals with async values. One is from Dan Abramov - Algebraic Effects for the Rest of Us, and the other is from  Radosław Miernik - On throw in React

Why use suspense?

As this good article explains, Suspense gives us two great features:

  • Declarative code which means that React Suspense simplifies the process of writing UI code by allowing developers to declare what they want the UI to look like rather than writing code that tells the UI how to render step-by-step. One example of this feature is the removal of the use isLoading variable. By placing this variable above the UI code, developers can ensure that their code is in sync with the UI. This eliminates the need for awkward if statements.
  • Better end-user experience with Suspense. You can use the newly added concurrent rendering engine in React. This Suspense feature gives the end user a better feeling of how the UI looks and acts because react only will update what it needs.

Theory aside, here is my gist, which is why this article exists. You can use this playground as well.

Now I will explain how this works. First, you will need a cache map to store your promises. As React suspends, the promise and then fulfills it, you need a way to track this specific promise (this was a part that I struggled with a lot to understand, it has to be the exact same promise each time). If there is no value in the given key, the hook will create an entry in the map with this promise and throw it. Here is where the magic happens. The suspensify hook looks like the snippet below in its first run:

function suspensify<T>(key: string, promise: Promise<T> | null, deps: DependencyList = [], lifespan = 0): T {
  const cached = cacheMap.get(key)
    **//    ˆˆˆ this is undefined. all code below is not ran
  /* ... code ...*/
 const entry: Entry = {
    deps,
    promise: new Promise((resolve, reject) => { // here you work in your promise / waitable value. remember to set the result and error
      promise
        .then((result) => {
          entry.result = result
          resolve(result)
        })
        .catch((error) => {
          entry.error = error
          reject(error)
        })
    })
  }

  cacheMap.set(key, entry)
  throw entry.promise
// ˆˆˆˆthis is where we jump into the fallback
}
Enter fullscreen mode Exit fullscreen mode

Before we talk about the return side of the suspense API, I would like to point out that when you pass in a null value is the same as saying that you want it to return null and not run the hook, as we cannot put an if statement above a hook, this is the way to deal with control flow.

Well, when the hook runs again (yes, that is how suspense works), we will go to the if that was above the entry creation:

function suspensify<T>(key: string, promise: Promise<T> | null, deps: DependencyList = [], lifespan = 0): T {
  const cached = cacheMap.get(key)
  //    ˆˆˆ now we have a value here
  useEffect(() =>
    () => {
      setTimeout(() => {
        if (cached !== undefined && isEqual(cached.deps, deps)) cacheMap.delete(key)
                // ˆˆˆˆclean when the hook unmounts.
      }, lifespan)
    }, [cached, key, ...deps])

  if (promise === null) return null // we talked about this one

  if (cached !== undefined) {
    if ('error' in cached) throw cached.error
                                                        // ˆˆˆˆtrigger error boundary
    if ('result' in cached) {
      const result = cached.result as T
      setTimeout(() => {
        cacheMap.delete(key)
      }, lifespan)
      return result // resolve suspense
    }
    throw cached.promise // as we have not got any result or err we should wait a bit more
  }

/* ... code ...*/
}
Enter fullscreen mode Exit fullscreen mode

There are some caveats. We need to address that setTimeout that works as “defer” they are necessary because after we return the value of the promise, we need to clean this key, as it may be required to run another code when this component mounts again. You can adjust the lifespan if you want to reuse the same value in other places but be careful not to live in old data, an excellent way to start is with 0 and then increase the lifespan of this key result.

And there you go, you have learned a bit about how Suspense works and how you can use it today to deal with your promises.

React Query already supports Suspense, and Relay is our most outstanding example of Suspense API in practice.

If you got interested in what you saw here and want to see implemented versions of a library using the suspense API, you could check the next chapter of this article series, where is shown the New Suspense hooks for Meteor

💖 💪 🙅 🚩
grubba
Gabriel Grubba

Posted on March 20, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related