React js 18 How did it improve the performance significantly? Is it because of the updates related to SSR?
The tipping point for me was React’s perennially unready Suspense API, React’s solution for async rendering. For the most part, I ignored talks and articles describing Suspense, partially because the React team kept signaling that the API was in flux, but mainly because most discussions of Suspense just seemed to go over my head. I assumed they would work it out, and we’d eventually have something like async/await for React components, so I continued to incorporate React into my projects without thinking too hard about the future of React and promises.
This was until I decided to explore the Suspense API for myself, when I was trying to create a React hook for usage with async iterators. I had created an async iterator library that I was proud of (Repeater.js), and I wanted to figure out a way to increase adoption, not just of the library, but also of async iterators in general. The answer seemed logical: create a React hook! At the time, it seemed like every API in existence was being transformed into a React hook somehow, and I thought it would be nice for there to be hooks which allowed developers to use async iterators within React components as well.
The result of this effort is available on GitHub, and the library is usable, but I mostly abandoned the effort and any sort of greenfield React development when I came to understand what Suspense was and how unwieldy it would have been to incorporate Suspense into the hooks I had written. As of April 2020, the mechanism behind Suspense is for components which make async calls to throw a promise while rendering to indicate that the component is doing something asynchronously. “Throw” as in the way you would throw an error in JavaScript with the throw operator. In short, React will attempt to render your components, and if a thenable is thrown in the course of rendering, React will catch it in a special parent component called Suspense, render a fallback if sufficient time has elapsed, and when the promise has fulfilled, attempt to render the component again. I say “as of April 2020,” because the React team has consistently said the exact details of the Suspense API might change and has used this declaration to preempt any possible criticisms of this mechanism. However, as far as I can tell, that’s how it will work, and how everyone who has written libraries featuring Suspense assumes it will work.
If this mechanism sounds wild to you, that’s because it is. It’s an unusual way to use promises and throw statements in JavaScript. And I could almost get past this, trusting that the React team knew what they were doing, until I understood the add-on ramifications of this design decision. When a component throws a promise to suspend, most likely that component has not rendered, so there’s no state or refs or component instance which corresponds to this thrown promise. And when the thrown promise fulfills, React will attempt to render the component again, and hopefully whatever API you called which initially threw the promise, an API which would otherwise be ill-behaved in regular JavaScript, would in this second rendering of the component, not throw a promise but return with the fulfilled value synchronously. This means that it doesn’t even matter what the thrown promise fulfills to; instead, it’s an elaborate way to notify React that your components are ready to try and render again.
All of a sudden, what little I had heard about React Suspense made sense. I understood, for instance, why discussions of Suspense almost always involved mentions of a cache. The cache is necessary because there is no component instance on which to store the thrown promise, so when the component attempts to render a second time, it needs to make the same calls to whatever API threw and hope that a promise is not thrown again. And while caching async calls is a useful technique for creating responsive, performant, offline-ready applications, I balked at the idea of this hard requirement of a cache when using promises.
This is because to cache an async call, you need two things. Firstly, you need to be able to uniquely key each call somehow. This is what would allow you to call a promise-throwing function a second time and have it “remember” not to throw a promise again. Secondly, you need to know when to invalidate the cached result. In other words, you need to be able to identify when the underlying data which the cached result represents might have changed, so that you don’t end up showing the user stale data.
Take a step back. Take a high-level look at any application you’re working on. If you’re using promises and async/await, think of the async calls you make, and whether you can both uniquely key each call, and know when to invalidate their results. These are hard problems; in fact, cache invalidation is one of the problems we joke about as being “the two hardest problems in computer science.” Even if you like the idea of caching your async functions, do you want to add this requirement when you’re making a one-off call to some random API, or when you’re trying to bootstrap a demo?
At this point my curiosity sublimated to frustration: Why can’t rendering just be async? Why can’t React components simply return a promise? I scoured GitHub for issues where people suggested this API change, and there was at least one such issue in each of the major JSX libraries (React, Preact, Inferno), but the maintainers either dismissed the issue or did not seem to consider it a high priority. For React, the issue was closed with a comment saying that Suspense would solve everything.
But Suspense solves this problem at the cost of requiring a cache, which as I described feels like such a huge ask. When I went and revisited the actual introductions to Suspense I felt like I was being gaslit. “Suspense allows you to access async data from a server as easily as sync data from memory,” a React maintainer would say in a talk introducing Suspense. But we already have a way to access async data as easily as sync data: it’s async/await syntax, and JavaScript will literally suspend your functions when promises are awaited. The literature on Suspense seemed to invent new problems with promises, like the idea that async code “waterfalls,” which in short just means that code which could run in parallel runs in sequence instead. It’s not a problem, I thought, because we have ways to make async functions run concurrently, for instance, by calling Promise.all over an array of promises. To me, nothing about React or virtual DOM implementations indicated that we couldn’t use similar solutions, and absolutely nothing indicated that the solution was to throw a promise.