Learn Suspense by Building a Suspense-Enabled Library
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⌗
You've Got Mail!
No more than five emails per year, all densely packed with knowledge.
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
ornull
) - 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⌗
You've Got Mail!
No more than five emails per year, all densely packed with knowledge.
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
.