Articles

How React Suspense Works Under the Hood: Throwing Promises and Declarative Async UI

Kent C. Dodds
Kent C. Dodds

Introduction: The Async UI Challenge in React

Fetching data in React is easyβ€”handling the user experience while waiting for that data is not. Spinners, loading states, and error messages often clutter our components and make code harder to maintain. Wouldn't it be great if React could handle async UI declaratively, just like it does with everything else?

That's exactly what React Suspense is designed to do. Let's look under the hood at how it works, why it's so clever, and how you can use it to simplify your async UI.

What is React Suspense?

React Suspense is a mechanism that lets you declaratively specify loading and error states for components that depend on asynchronous data. Instead of manually tracking loading and error state, you wrap your component tree in <Suspense> and let React handle the rest.

Here's the wild part: Suspense works by catching thrown promises 😱. When a component needs data that isn't ready, it throws a promise. React catches that promise, pauses rendering, and shows the fallback UI you provided to <Suspense>. When the promise resolves, React tries rendering again.

This is possible because in JavaScript, you can synchronously stop a function by throwing. React leverages this to "pause" rendering until the data is ready.

How the use Hook Works with Suspense

The use hook in React 19+ is the key to this pattern. Instead of using await, you pass a promise to use, and it either returns the resolved value (if ready) or throws the promise (if not):


function PhoneDetails() {
const details = use(phoneDetailsPromise)
// details is ready here!
}

But where does phoneDetailsPromise come from? You should trigger the fetch outside the render, so you don't start a new request on every render. For example:


// this could be in an event handler or something, just not within the body of
// a client component (server components can't use `use` and can just use
// `fetch` directly).
const phoneDetailsPromise = fetch('/api/phone-details').then((res) =>
res.json(),
)

If the promise isn't resolved, use throws it, triggering Suspense. If it's resolved, you get the value.

Error Handling with Suspense and Error Boundaries

If the promise rejects, React will look for an Error Boundary and render its fallback. This lets you handle errors declaratively, just like loading states:


import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
function App() {
return (
<ErrorBoundary fallback={<div>Oh no, something bad happened</div>}>
<Suspense fallback={<div>Loading phone details...</div>}>
<PhoneDetails />
</Suspense>
</ErrorBoundary>
)
}

Practical Example: Building a Simple Suspense Data Fetcher

Let's build a simple Suspense data fetcher from scratch:


let userPromise
function fetchUser() {
userPromise = userPromise ?? fetch('/api/user').then((res) => res.json())
return userPromise
}
function UserInfo() {
const user = use(fetchUser())
return <div>Hello, {user.name}!</div>
}
function App() {
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserInfo />
</Suspense>
)
}

Notice how there's no manual loading or error state in UserInfo. Suspense and Error Boundaries handle it all.

You could imagine how to expand this example to be more complex (parameterized, cached, etc.), and I'll invite you to join me in the Epic React Suspense Workshop to dive deeper into all of that πŸ˜‰. In fact, we build our own implementation of the use hook from scratch so you really understand what's going on under the hood.

Conclusion

This pattern is powerful because it is:

  • Declarative: No more manual state management for loading/errors.
  • Composable: Works with any async resourceβ€”data, images, even code.
  • Scalable: Suspense boundaries can be nested for fine-grained control.
  • Future-proof: This is the "blessed" way React handles async UI in the client.

React Suspense is great for async UI. By "throwing promises" and using the use hook, you can write cleaner, more declarative components that handle loading and error states automatically. Try it out in your next project and see how much simpler your code can be!

Get my free 7-part email course on React!

Delivered straight to your inbox.