Suspense (along with concurrent rendering) has been a feature in React since v16.6.0. Despite this, I haven’t seen much of it in action beyond React.lazy and limited applications of “suspense-enabled libraries”.

What’s going on?

As of the impending React v19 release, Suspense is still not quite ready for primetime. The story of its APIs and internals still seems incomplete. In fact, the React team seems to think it’s so incomplete that the Suspense API is entirely undocumented. The Suspense documentation insists that the only way of using Suspense is via “Suspense-enabled frameworks”.

I think that purposefully hiding APIs in documentation is silly, but fine! I’ll play their game! Let’s build a Suspense-enabled library, and use it.

We will peel back the curtain of Suspense along the way.

The Philosophy

Before jumping straight into Suspense, let’s build a simple data-fetching component to see where we are without it.

If you take a React 101 course, it will generally teach you to write code like this when fetching data:

const Artist = ({ artistName }) => {
    const [artistInfo, setArtistInfo] = useState(null)
    
    useEffect(() => {
        fetch(`https://api/artists/${artistName}`)
            .then(res => res.json())
            .then(json => setArtistInfo(json))
    }, [artistName])

    return (
        <div>
            <h2>{artistName}</h2>
            <p>{artistInfo.bio}</p>
        </div>
    )
}

The next thing that course will teach you is that this code is actually bad. It’s missing a bunch of things like:

  • Error state handling
  • Pending state handling
  • Race condition handling
  • Shared caching (this will be covered later)

Once implemented, our component looks more like this:

const Artist = ({ artistName }) => {
    const [artistInfo, setArtistInfo] = useState(null)
    const [error, setError] = useState(null)
    const [isPending, setIsPending] = useState(false)

    useEffect(() => {
        let stale = false
        setIsPending(true)
        setError(null)

        fetch(`https://api/artists/${artistName}`)
            .then(res => res.json())
            .then(json => {
                if (!stale) {
                    setIsPending(false)
                    setArtistInfo(json)
                }
            })
            .catch(err => setError(err))

        // This is basically what AbortController does
        return () => { stale = true }
    }, [artistName])

    if (isPending) return <SpinnerFallback />
    if (error !== null) return <ErrorFallback />

    return (
        <div>
            <h2>{artistName}</h2>
            <p>{artistInfo?.bio}</p>
        </div>
    )
}

At this point, the course instructor will usually tell you that this is good, but you’re bound to be repeating this code every time you need to fetch data, and will then proceed to teach you about writing your first hook.

While I respect the pedagogical needs, that is actually not the best way to approach it. The problem with tracking pending and error states is that even if you bury it in a hook, you will still need to handle those states at the component level.

const Artist = ({ artistName }) => {
    const [artistInfo, isPending, error] = useFetch(`https://api/artists/${artistName}`)

    // This isn't much, but you will need to do this every time
    if (isPending) return <SpinnerFallback />
    if (error !== null) return <ErrorFallback />

    return (
        <div>
            <h2>{artistName}</h2>
            <p>{artistInfo.bio}</p>
            <Album albumName={artistInfo.lastAlbumName}>
        </div>
    )
}

const Album = ({ albumName }) => {
     const [albumInfo, isPending, error] = useFetch(`https://api/artists/${albumName}`)

    // This isn't much, but you will need to do this every time
    if (isPending) return <SpinnerFallback />
    if (error !== null) return <ErrorFallback />

    return ...
}

Imagine if useFetch could handle returning SpinnerFallback and ErrorFallback for us.

In the case of ErrorFallback, we have error boundaries:

const Artist = ({ artistName }) => {
    const [artistInfo, isPending, error] = useFetch(`https://api/artists/${artistName}`)

    if (isPending) return <SpinnerFallback />
    // Handle at the nearest error boundary (you can move this into useFetch)
    if (error !== null) return throw error

    return (
        <div>
            <h2>{artistName}</h2>
            <p>{artistInfo.bio}</p>
            <Album albumName={artistInfo.lastAlbumName}>
        </div>
    )
}

const App = () => {
    return (
        <ErrorBoundary fallback={<ErrorFallback />}>
            <Artist artistName="Julian Casablanca" />
        </ErrorBoundary>
    )
}

As for SpinnerFallback, that is actually what Suspense is for. We’ll eschew the implementation that triggers the fallback mechanism to avoid distractions (we’ll come back to it later, don’t worry!), but it looks like this from the component perspective:

const Artist = ({ artistName }) => {
    // Wow! Our hook implicitly handles both errors, and loading states now!
    const [artistInfo] = useFetch(`https://api/artists/${artistName}`)

    return (
        <div>
            <h2>{artistName}</h2>
            <p>{artistInfo.bio}</p>
            <Album albumName={artistInfo.lastAlbumName}>
        </div>
    )
}

const App = () => {
    return (
        <ErrorBoundary fallback={<ErrorFallback />}>
            <Suspense fallback={<SpinnerFallback />}>
                <Artist artistName="Julian Casablanca" />
            </Supense>
        </ErrorBoundary>
    )
}

At this point, don’t dwell on how useFetch is implemented. We have a long way to go before it’s usable.

Beware: Boundaries Reset State

A side-effect of using boundaries for handling pending and error states is that when a boundary is hit, all its children are discarded in favor of the fallback. The result is that their state is lost.

const Counter = () => {
    // All state will be reset once an error boundary is hit
    const [count, setCount] = useState(0)
    const [error, setError] = useState<null | Error>(null)

    // trigger error boundary
    if (error) throw error

    const increment = () => setCount((n) => n + 1)
    return (
        <div>
            <p>Counter: {count}</p>
            <button onClick={increment}>Increment</button>
            <button onClick={() => {
                    // To trigger an error boundary, an error MUST be
                    // thrown during the render cycle.
                    // Throwing an error in an event handler or effect
                    // will not trigger an error boundary!
                    setError(new Error("Whoops something went wrong!"))
                }}
            >Throw Error</button>
        </div>
    )
}

Try it out:

This is often desirable. For example, if your component entered a bad state, the surest way of getting it out is a complete reset.

Tip: by strategically placing your boundaries, you can control which parts of your application are reset together.
Remember that you can wrap more than just your root, but you also don’t need to wrap every single component.
This is true for both error boundaries and suspense boundaries.

In terms of tracking pending states, this poses a challenge. On the initial render, the state is pending. This will trigger the Suspense boundary. If the Suspense boundary is reset, then the component will remount, send a new request, and trigger the boundary again. As such, successfully displaying data is not possible under such a paradigm without having a continuous reference to the same request.

A cache that lives longer than the component lifetime is necessary.

Building a Shared Cache

Shared caching is important. It’s essential to avoid out-of-sync data, sending too many requests, and implementing advanced data management features. In our case, it’s also critical to make components dumber, and not responsible for managing the state of their requests, which would otherwise be lost.

Caching is hard to do right. In truth, it’s hard enough that data-fetching libraries are mostly caching libraries.

We’ll avoid going down the “how to write a cache” rabbit hole by keeping our cache intentionally simplistic. Its API will allow us to request a specific URL from it, and it will give us back values for data, pending, and error just like before. The only difference will be that the request lifecycle will be managed within the cache – not within the component making the request.

We will then turn the cache into a real Suspense-enabled library.

FetchCache Class

You can get creative with the exact APIs, and features your cache has. Here’s a minimal implementation:

class FetchCache {
    // Container for all-things-requests
    requestMap = new Map()

    // Tracking callbacks for broadcasting state updates
    subscribers = new Set()

    fetchUrl(url, refetch) {
        const currentData = this.requestMap.get(url)

        if (currentData) {
            // This request is already in flight. 
            if (currentData.status === 'pending') 
                return currentData
            // If data is already in cache and has not been 
            // explicitly re-requested. Return it.
            // status is either fulfilled or rejected.
            if (!refetch) return currentData
        }

        // Set status to pending to avoid competing requests
        const pendingState = { status: 'pending' }
        this.requestMap.set(url, pendingState)

        const broadcastUpdate = () => {
            // Delay notification in order to not run
            // during render cycle
            // https://reactjs.org/link/setstate-in-render
            setTimeout(() => {
                for (const callback of this.subscribers) {
                    callback()
                }
            }, 0)
        }
        
        // Dispatch request and observe it
        fetch(url)
            .then(res => res.json())
            .then(data => {
                // Put success state into cache
                this.requestMap.set(url, { status: 'fulfilled', value: data })
            })
            .catch(error => {
                // Put error state into cache
                this.requestMap.set(url, { status: 'rejected', reason: error })
            })
            .finally(() => {
                // Whatever happens, notify subscribers 
                // that state should be refreshed
                broadcastUpdate()
            })

        // Report that a request is now pending
        broadcastUpdate()

        // Report that a request is now pending
        return pendingState
    }

    subscribe(callback) {
        this.subscribers.add(callback)
        return () => this.subscribers.delete(callback)
    }
}

This FetchCache has 2 methods:

  • fetchUrl(url, refetch)

Request a URL.
Returns its current state in the cache.

  • subscribe(callback)

Register a callback for notifications when new data is available in the cache.
Returns a function to unsubscribe.

FetchCache Provider

Now that we have a cache, we require the means to expose it to our React tree: a Context Provider.

const fetchCacheContext = createContext(null)

const FetchCacheProvider = ({ children, fetchCache }) => {
    // This state hook is only used to trigger re-renders
    const [,setEmptyState] = useState({})
    const rerender = useCallback(() => setEmptyState({}), [])

    // Effect to register subscriber onto fetchCache
    useEffect(() => {
        const unsubscribe = fetchCache.subscribe(() => rerender())
        return unsubscribe
    }, [fetchCache, rerender])

    return (
        <fetchCacheContext.Provider value={{
            // Pop quiz: why is `bind` necessary here?
            fetchUrl: fetchCache.fetchUrl.bind(fetchCache)
        }}>
            {children}
        </fetchCacheContext.Provider>
    )
}

useFetch Hook

Lastly, we will write a useFetch hook to leverage the FetchCacheProvider:

const useFetch = (url) => {
    const { fetchUrl } = useContext(fetchCacheContext)
    const state = fetchUrl(url)
    const isPending = state.status === 'pending'
    const error = state.reason
    const data = state.value
    
    // Allow refreshing data
    const reload = () => fetchUrl(url, true)

    return [data, isPending, error, reload]
}

Note that we’re keeping the API consistent with our initial example.

In Action!

This demo uses Users and Posts instead of Artists and Albums. Powered by https://jsonplaceholder.typicode.com

Try clicking the Refresh buttons and see what happens. Break the URLs to make the request purposefully fail.

Feel free to explore the codebase, and make sure you have a good understanding of what’s going on until we continue. Re-read previous sections if something doesn’t make sense.

Tracking Data as Promises

So far, we’ve been consistently using promises without thinking too much about them. Remember that Promises have 3 possible states:

  • Pending
  • Fulfilled with value (where value could also be undefined or null)
  • Rejected with reason (where the reason is typically an Error, but doesn’t need to be)

In principle, we don’t need to track this information separately from the Promise, as we do in FetchCache. We could just track the promise by itself while using a special function to read information from it.

The challenge here is that Promises only allow for asynchronous data access (even if they are already settled):

// Created settled promise
const promise = Promise.resolve()
console.log("1")
promise.then(() => {
  console.log("3")
})
console.log("2")

// Outputs: 
1
2
3

We need a way to read its state synchronously to extract the data. We can do this by adding extra properties to the Promise object to track its state.

function readPromiseState(promise) {
    switch (promise.status) {
        case 'pending':
            return { status: 'pending' }
        case 'fulfilled':
            return { status: 'fulfilled', value: promise.value }
        case 'rejected':
            return { status: 'rejected', reason: promise.reason }
        default:
            promise.status = 'pending'
            promise.then(value => {
                promise.status = 'fulfilled'
                promise.value = value
            })
            promise.catch(reason => {
                promise.status = 'rejected'
                promise.reason = reason
            })
            return readPromiseState(promise)
    }
}

The first time we call readPromiseState with a Promise, it will return pending. But once it settles, it will update its own state in a way that is accessible to us.

Try in your REPL of choice:

> const promise1 = Promise.resolve("Hello World!")
> readPromiseState(promise1)
{ status: 'pending' }
> readPromiseState(promise1)
{ status: 'fulfilled', value: 'Hello World!' }

> const promise2 = Promise.reject(new Error("Whoops!"))
> readPromiseState(promise2)
{ status: 'pending' }
> readPromiseState(promise2)
{ status: 'rejected', reason: [Error: Whoops!] }

> const promise3 = new Promise(res => setTimeout(res, 5000))
> readPromiseState(promise3)
{ status: 'pending' }
> readPromiseState(promise3)
{ status: 'pending' }
> readPromiseState(promise3)
{ status: 'pending' }
> readPromiseState(promise3)
{ status: 'fulfilled', value: undefined }

Spoiler: You might think that this is improper, but this is exactly what React v19’s use does under the hood.

Using Promises in the Cache

In this section, we will lightly modify our FetchCache to track Promises using readPromiseState instead of state objects. The changes are fairly light.

class FetchCache {
    // Container for all-things-requests
    requestMap = new Map()

    // Tracking callbacks for broadcasting state updates
    subscribers = new Set()

    fetchUrl(url, refetch) {
        const currentData = this.requestMap.get(url)

        if (currentData) {
            // This request is already in flight. 
            if (readPromiseState(currentData).status === 'pending') 
                return readPromiseState(currentData)
            // If data is already in cache and has not been 
            // explicitly re-requested. Return it.
            // status is either fulfilled or rejected.
            if (!refetch) return readPromiseState(currentData)
        }

        

        const broadcastUpdate = () => {
            // Delay notification in order to not run
            // during render cycle
            // https://reactjs.org/link/setstate-in-render
            setTimeout(() => {
                for (const callback of this.subscribers) {
                    callback()
                }
            }, 0)
        }
        
        // Dispatch request and observe it
        const newPromise = fetch(url)
            .then(res => res.json())


        newPromise.finally(() => {
            // Whatever happens, notify subscribers 
            // that state should be refreshed
            broadcastUpdate()
        })

        this.requestMap.set(url, newPromise)

        // Report that a request is now pending
        broadcastUpdate()

        // Report that a request is now pending
        return readPromiseState(newPromise)
    }

    subscribe(callback) {
        this.subscribers.add(callback)
        return () => this.subscribers.delete(callback)
    }
}

You can paste it into your fork of the previous CodeSandbox to see that no other changes are necessary. This demonstrates that we have all the data we need in the Promise itself.

We will now make one more change, where instead of using readPromiseState in the FetchCache, we will move to the useFetch hook.

class FetchCache {
    // Container for all-things-requests
    requestMap = new Map()

    // Tracking callbacks for broadcasting state updates
    subscribers = new Set()

    fetchUrl(url, refetch) {
        const currentData = this.requestMap.get(url)

        if (currentData) {
            // This request is already in flight. 
            // We need to keep this `readPromiseState` check
            if (readPromiseState(currentData.status) === 'pending') 
                return currentData
            // If data is already in cache and has not been 
            // explicitly re-requested. Return it.
            // status is either fulfilled or rejected.
            if (!refetch) return currentData
        }

        

        const broadcastUpdate = () => {
            // Delay notification in order to not run
            // during render cycle
            // https://reactjs.org/link/setstate-in-render
            setTimeout(() => {
                for (const callback of this.subscribers) {
                    callback()
                }
            }, 0)
        }
        
        // Dispatch request and observe it
        const newPromise = fetch(url)
            .then(res => res.json())


        newPromise.finally(() => {
            // Whatever happens, notify subscribers 
            // that state should be refreshed
            broadcastUpdate()
        })

        this.requestMap.set(url, newPromise)

        // Report that a request is now pending
        broadcastUpdate()

        // Report that a request is now pending
        return newPromise
    }

    subscribe(callback) {
        this.subscribers.add(callback)
        return () => this.subscribers.delete(callback)
    }
}
const useFetch = (url) => {
    const { fetchUrl } = useContext(fetchCacheContext)
    const state = readPromiseState(fetchUrl(url))
    const isPending = state.status === 'pending'
    const error = state.reason
    const data = state.value
    
    // Allow refreshing data
    const reload = () => fetchUrl(url, true)

    return [data, isPending, error, reload]
}

Enable Suspense 🎉

The final change we’re going to make is to turn useFetch into a Suspense hook.

Remember how we would trigger the error boundary by throwing an Error? Triggering a Suspense boundary is the same, except you throw a Promise.

To escape an error boundary, you need to have some code that makes the call to reset it. The Suspense boundary on the other hand will automatically reset itself when the Promise is resolved (or will trigger an error boundary if it’s rejected).

Enabling Suspense for useFetch will require 2 changes.

First, it will need to throw the Promise if it’s pending.

Second, it will need to throw the reason (which is an Error) if the Promise is rejected.

If the Promise has been fulfilled, it can simply return the data. The consuming component no longer needs to consider error or pending states.

const useFetch = (url) => {
  const { fetchUrl } = useContext(fetchCacheContext)
  const promise = fetchUrl(url)
  const state = readPromiseState(promise)

  // Throw pending promise
  const isPending = state.status === "pending"
  if (isPending) throw promise

  // Throw rejection reason
  const error = state.reason
  if (error) throw error

  const data = state.value

  // Allow refreshing data
  const reload = () => fetchUrl(url, true)

  // Only return data now
  return [data, reload]
}

Next, update the components that use useFetch to consume the data directly…

export const User = ({ userId }) => {
  const [data, reload] = useFetch(
    `https://jsonplaceholder.typicode.com/users/${userId}`
  );
// ...

…and wrap the application in a Suspense and ErrorBoundary.

function App() {
    return (
        <div className="App">
            <FetchCacheProvider fetchCache={fetchCache}>
                <ErrorBoundary fallback={<ErrorFallback />}>
                    <Suspense fallback={<Spinner />}>
                        <h1>My App</h1>
                        <User userId={1} />
                    </Suspense>
                </ErrorBoundary>
            </FetchCacheProvider>
        </div>
    )
}

A best practice here is to always wrap your application root in both ErrorBoundary and Suspense. Remember to be mindful that anything contained in either of them will have their local state reset when the boundary is triggered.

Try it on the CodeSandbox:

Exercise for the reader: This time, when you click the refresh buttons, the entire app turns into a loading state. Why is this, and how can you restore the previous behavior?
Hint: throwing an error or a promise triggers the nearest boundary.

Whoops. We Just Re-Invented use()!

React v19 is introducing a new hook: use. It can consume data from a context (similar to useContext) or a Promise. Let’s see what it looks like when we introduce use to useFetch:

const useFetch = (url) => {
  const { fetchUrl } = useContext(fetchCacheContext)
  const promise = fetchUrl(url)

  // Handles throwing for pending and rejected promises
  const data = use(promise)

  // Allow refreshing data
  const reload = () => fetchUrl(url, true)

  // Only return data now
  return [data, reload]
}

Neat!

Review

In this post, we have learned:

  • How Suspense helps with writing shorter component functions
  • How to persist state when a boundary is triggered
  • How to read from a Promise “synchronously”
  • How to write a “Suspense-enabled” hook
  • What the use() hook does to Promises

Ending Notes

Congratulations on finishing reading! If you followed along, you should now have the knowledge needed to build your own Suspense-enabled hooks. More importantly, you should now feel empowered in debugging Suspense when things go wrong.

While some of the implementations featured in the post might seem awkward; everything is broadly representative of how real Suspense-enabled libraries work.

For example, if you look at TanStack Query, the mappings should be pretty clear:

  • useQuery -> useFetch
  • QueryClient -> FetchCache
  • QueryClientProvider -> FetchCacheProvider

The practical differences are in the features available.

FetchCacheProvider Implementation

While we used a global instance of FetchCache for persisting the fetch states, it could’ve been a locally initialized instance within the context, as long as it was outside the Suspense and Error boundaries, which would otherwise reset it:

const FetchCacheProvider = ({ children }) => {
    const [fetchCache] = useState(() => new FetchCache())
    // This state hook is only used to trigger re-renders
    const [,setEmptyState] = useState({})
    const rerender = useCallback(() => setEmptyState({}), [])

    // Effect to register subscriber onto fetchCache
    useEffect(() => {
        const unsubscribe = fetchCache.subscribe(() => rerender())
        return unsubscribe
    }, [fetchCache, rerender])

    return (
        <fetchCacheContext.Provider value={{
            // Pop quiz: why is `bind` necessary here?
            fetchUrl: fetchCache.fetchUrl.bind(fetchCache)
        }}>
            {children}
        </fetchCacheContext.Provider>
    )
}

This implementation of the provider would’ve worked just as well. It is, however, bad practice.

Writing the FetchCacheProvider in this way creates a tight coupling between it and the specific FetchCache implementation. In turn, this makes FetchCacheProvider unable to accept alternative implementations that use a different fetching mechanism (Axios, GraphQL, mocks for unit tests, etc).

What is missing from Suspense?

Render-level caching is something that the React team has previously alluded to delivering. The idea is that we could cache data (like Promises) within some internal React context, which would survive state resets.

This could allow Suspense implementations based on “local” state, that doesn’t require a context or global cache. It’s still a moving target in terms of implementation, so there’s not much else to say.

Aside from that, Suspense API seems pretty complete to me. I don’t understand the React team’s aversion to documenting it.

Transitions

I won’t be going into specifics in this post, but the documentation is pretty good as far as Suspense-Transition interactions go. If you’re building on Suspense, that page is mandatory reading.

Especially with the upcoming v19 changes, there is much more to useTransition than just Suspense. I’ll be covering the hook in its entirety in a future post after the official v19 release.

Subscribe to the newsletter to be the first to the complete guide to useTransition.