
Promise Me No [Thrown] Promises
I won’t go into all the juicy (or boring) details, but the short version is that I spent an entire day debugging a production issue where every modal in our React application could permanently stop opening. The cause was a pending promise stored in application state.
This may already sound like an emotional, venting post, but I “promise” it’s intended to be a short reflection on good software practices—and why React normalizing thrown promises isn’t one of them.
If you’re wondering why this bothers me so much, you should know that I’m a big fan of Result and Option types. I only throw errors when true invariant violations occur, and I make them as explicit as possible. In production apps where I have enough technical influence, you’ll generally see a thrown error right alongside a notification to Sentry. In other words, my ethos is: don’t throw, but if you do, only do it when things are fundamentally broken, and make it visible.
React emerged from an engineering culture strongly influenced by functional programming. Meta later created Reason, based on OCaml, and React still contains terminology and patterns familiar to functional programmers. Fun fact: refs in React are almost certainly borrowed from refs in OCaml, which represent a way to make mutation explicit—so the OCaml flavor is still present in parts of React.
This style of engineering is functional. Throwing errors isn’t explicitly forbidden, but it is used much less and is virtually never hidden.
With that history in place for context, enter Suspense, which advertises the clever feature that a promise can be thrown and caught by a Suspense boundary if it’s pending. I argue that this is an especially nasty practice and is much worse than throwing errors for the following reasons:
-
At least thrown errors generally show up in the developer console. React’s Suspense boundaries catch thrown promises silently. If one causes an issue, it can be difficult to discover that a thrown promise is responsible.
-
As with throwing exceptions, throwing promises is an implicit form of control flow akin to a
gotostatement. It is harder to model mentally when reading the code and is error-prone for this reason. The destination of the control transfer is not visible at the throw site. You must inspect the surrounding runtime structure to know where execution will resume and what portion of the component tree will disappear. -
Certain parts of the React ecosystem—Jotai, in the case from my work—will unexpectedly throw promises as a “convenience.” So now your subscription to a state management library throws not errors, but promises, without logging anything? Yeah, that’s slick. Sarcasm intended.
So how did we get here? How did React move from origins that put a high premium on explicit control flow to what feels like a hack of JavaScript’s throwing capabilities just to show a spinner? We’ve traded rigorous, intentional state management for an implicit control-flow mechanism that is difficult to observe and reason about.
The solution is to stop throwing promises and return to deliberately modeling and managing state. You might start with a discriminated union like this:
type AsyncState<T> =
| { status: "not-started" }
| { status: "pending" }
| { status: "success"; value: T }
| { status: "error"; error: Error };
When state is modeled this way, every subscriber must explicitly acknowledge that an operation may not have started, may still be pending, may have succeeded, or may have failed. There are no hidden control-flow effects, and invalid assumptions become harder to make because the model clearly describes every form the state can take.
Loading is not exceptional. It is ordinary application state. Model it, name it, and handle it explicitly.