@radekmie
throw
in ReactBy Radosław Miernik · Published on · Comment on Reddit
Library authors are doing their best to provide the cleanest APIs while being constrained by the language. The (clean) API itself is there not only to help people understand the code better without diving into implementation details but also to prevent them from foot guns of any sort.
Of course, the bigger the target audience is, the more vital the API becomes. If it’s not going to be “good enough”, then the community won’t hold itself, as new people won’t settle in quickly. Quality over performance!
What if we abuse use some language features in an unexpected way to achieve an excellent developer experience? On the one hand, we achieved our goal; at least partially. On the other, it may result in interesting behavior. That’s how I see the future (of React).
The role of exceptions in programming is to signal that something happened. It doesn’t have to be a reason to terminate the program immediately – they are often a result of parsing an incorrect user input or network failure. Of course, it’s better to parse than validate, but it’s not so popular approach… Yet.
There are also errors – traditionally unrecoverable – often coming from outside of our programs (OS, etc.). Probably, the most well-known is the stack overflow error; for the second one, I’d bet on out-of-memory error. However, it’s pretty common not to distinguish them anymore.
Over the years, exceptions (and errors; I’ll use both terms interchangeably) become a control flow structure, just like if
or while
. I think that exceptions used to control the program flow are not a good idea at all. Is it an anti-pattern? Well, it depends.
Python goes even further and utilizes exceptions for such a basic operation as iteration. And yet, it’s not that big of a deal, as existing language constructs (for
) handle the StopIteration
exception implicitly.
An alternative approach, promoted by most (all?) of the functional languages and libraries, namely using Result
type, is easier to understand and to build tooling around. The most important point here is that it doesn’t require any extensions of the syntax or type-system1.
In React, an error can occur in a bazillion of different places. It may happen synchronously in the render function, all hooks (useReducer
and useState
have lazy initializers, remember?), all class components lifecycle methods (including componentDidCatch
), event handlers (on*
), and asynchronously, in setTimeout
, setInterval
, or somewhere in a Promise
chain.
What if an error actually occurs? It doesn’t have to be thrown explicitly – it can be a bug or some unhandled case. In this case, you’ll see a message like this one:
The above error occurred in the
<Example>
component: atExample
. Consider adding an error boundary to your tree to customize error handling behavior. Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries.
The error boundary is a standard React component, implementing either the componentDidCatch
or static getDerivedStateFromError
. Simple as that, but handles only the synchronous errors – all of the others have to be handled “manually”, outside of React.
But is it actually true that it handles all errors? Yes and no. If we’d throw
an Error
instance, a string, a number, or some other object – it will be caught, as expected. We can even throw null
or undefined
, and it’ll still work2.
But what if we throw
a Promise
?
If you somehow managed to “accidentially” throw
a Promise
, React will warn you with an entirely different and surprising message:
Example
suspended while rendering, but no fallback UI was specified. Add a<Suspense fallback=...>
component higher in the tree to provide a loading indicator or placeholder to display.
Wait, what? Why React would use such an approach for… Anything!? The point is that it doesn’t even matter. What is more, they explicitly tell us not to focus on the API just yet:
Caution:
This page was about experimental features that aren’t yet available in a stable release. It was aimed at early adopters and people who are curious.
Much of the information on this page is now outdated and exists only for archival purposes. Please refer to the React 18 Alpha announcement post for the up-to-date information.
Before React 18 is released, we will replace this page with stable documentation.
At the same time, Relay – Facebook’s framework to work with GraphQL – is already Suspense-ready. I agree, they had to test it to get a proper feedback from the community. The actual API is only an implementation detail – it’s going to be part of some nice, high-level API, suitable for everyday use; most probably accompanied by a hook.
At this point, I think we can all agree that this abuse clever usage of throw
is not ideal. Let’s think about the alternatives then. Our task is to have a sane API that can handle asynchronous operations in a synchronous manner.
Wait, that’s precisely what async
/await
is for, right? Yes, but then the entire React stack has to be aware of Promise
s. That’s not bad on its own, but it’d lead to a lot of complexity, as Promise
objects are not introspectable – all we can do is to .then
them and hope for a result. Also, there’s no way back – once we wrap anything in a Promise
, anything we’ll do with it will remain wrapped.
Another, very similar, would be to use generators. These are already better, as they don’t poison everything with asynchronicity. However, again, the entire stack would have to be aware of generators everywhere.
Finally, let’s imagine JavaScript would introduce algebraic effects. I’d love to go into details here, but that’s not the point. If you’d like to go the “hard way”, here’s an introduction. If you’d like to go the “easy” one instead, do read this article from Dan Abramov. Or just skip both and let me (vaguely!) explain.
The idea is simple – instead of await
, yield
, and throw
, we’d like to have a single language feature (it has to be a part of the language!) that can handle all of them. It means it can “suspend” (!) the function execution but also resume it, maybe with some value. In terms of the three, it’d behave just like yield
, but it’d propagate like throw
– up to the nearest handler.
And just like with throw
and errors, we can handle the effects at any level. We could have an async
handler (await
), a yield
handler (for..of
), a throw
handler (catch
), and finally, a suspense handler (<Suspense>
). Yes, we could define our own – and as many as we’d like to.
I don’t think it’ll happen, though. There are already some advanced languages that support algebraic effects – for example, Eff and Koka. However, virtually no one “in the mainstream” has ever heard of them, let alone used them. I agree that these are important – that’s the bleeding edge of language design, and we need those to progress. I’d say we’d rather switch the entire language rather than extend JavaScript or Python with effects.
While working “within” a language or a framework, we have to fit in. It’s not bad per se, but it’ll always result in limitations. One time it’s the API, another it’s the syntax. Here we’re “constrained with JavaScript” and that’s all3.
Whether this <Suspense>
API will stay or the React team will come up with a different approach doesn’t really matter. It’s just an API. We’ll use it. We’ll build tools around it. We’ll manage. We always did.
In Java, there’s a concept of checked exceptions. The idea is simple – every function (method) carries the information about all the possible exceptions it can throw. If you’d like to read more about them, definitely check out this Stack Overflow thread. There’s also this proposal, to add it to TypeScript. Nim has its exception tracking, which is basically the same idea but allows us to prove that a function won’t throw anything.
Please, don’t throw
a null
or undefined
. Yes, you technically can do that, but you definitely shouldn’t. It’s terrible to handle these. Luckily, TypeScript 4.4 introduced the --useUnknownInCatchVariables
flag.
Even with a new Babel plugin, TypeScript feature, or even an entire language transpiled to JavaScript, we’re still being constrained by the host language. It may be the case that we actually will, and no one will ever throw
an actual Promise
object, but the bundled code shipped to the browser will.