Video Summary and Transcription
Hi, my name is Julian, and I'm extremely excited to talk about suspense. Suspense allows you to render fallback UI while waiting for asynchronous tasks to resolve, making it the unsung hero of React components. In today's talk, I will explain why suspense is crucial for React server components. Before diving into server components, let's understand the history of web rendering and the problems it posed. We then introduced server-side rendering (SSR) and static-side generation (SSG) to address these issues. However, SSR had problems like large bundle sizes, slow hydration, and sluggish initial server response time. React server components solve these problems by allowing us to differentiate between static and dynamic components. But to address the third problem, we need suspense. Today, we'll build a simplified version of suspense on the server to demonstrate its conceptual working and how it improves the rendering process from classical SSR to streaming and out-of-order streaming. Let's dive into the code by building a movie app called Notflix. We have different sections like the title, cast members, and similar movies. The components fetch their own data asynchronously, making them server components. In the classical way of server-side rendering, we loop through the children, execute them as server components, and render the HTML response. To improve the user experience, we introduce streaming, which allows us to start the response on the server, keep the connection open, and add to the response document as data becomes available. By using the write method provided by Express, we can write to the response stream instead of collecting all the HTML. Dealing with promises in sequence ensures that components are rendered in the correct order. Although the server-side rendering has improved, there is still no loading state or proper handling of suspended children. To address this, we introduce suspense on the server and build a suspense component with a fallback UI and children. We keep track of suspended children using a simple object with unique identifiers. In the renderer, we check the suspended object and loop through the entries to handle each suspended child. To replace the loading state with the content once it's available, we need to execute asynchronous functions to collect and concatenate the content, delete the entry from the suspended object, and use JavaScript to handle the swapping of elements in the browser. To replace the fallback renderer with the content, we need to wrap the fallback in a diff and add an identifier using a data attribute. We can use the CSS trick of 'display: contents' to prevent the diff from affecting the layout. Next, we wrap the available content in a template tag to add it to the document without rendering. Finally, we use custom elements and a connected callback to swap the loading boundary with the content based on the identifier. This allows us to replace multiple fallback renderers. By wrapping each section in its own boundary, the user experience is significantly improved as each section can load independently. This approach also emphasizes the importance of using the platform's existing features and functionality, such as browser caching, to enhance performance. Thank you for watching and enjoy the rest of the conference!
1. Introduction to Suspense in React
Hi, my name is Julian, and I'm extremely excited to talk about suspense. Suspense allows you to render fallback UI while waiting for asynchronous tasks to resolve, making it the unsung hero of React components. In today's talk, I will explain why suspense is crucial for React server components.
Hi, my name is Julian, and I'm extremely bummed out that I can't be in London in person for React advanced, but I hope I still got a somewhat interesting session for all of you all the way from down under. So let's dive into it.
I want to talk about suspense and a disclaimer up front. I'm a huge fan of suspense ever since they started demoing like preview versions back in 2016. For those of you who don't know, suspense basically allows you to render boundaries within your application, to render fallback UI, usually some kind of loading state, while anything underneath that boundary in your application tree is still waiting for something asynchronous to resolve. So that could be API calls to a third party API, that could be lazy loading JavaScript chunks, you name it. And that's pretty cool on the client. But I think suspense on the server is equally impressive. And I would even go as far as say, it's the unsung hero of React components, because React server components the way we know them wouldn't really be feasible without suspense. And in today's talk, I hope I can show why I think that is, and hopefully demystify a few things that are happening under the hood.
2. Introduction to Server Components and Suspense
Before diving into server components, let's understand the history of web rendering and the problems it posed. We then introduced server-side rendering (SSR) and static-side generation (SSG) to address these issues. However, SSR had problems like large bundle sizes, slow hydration, and sluggish initial server response time. React server components solve these problems by allowing us to differentiate between static and dynamic components. But to address the third problem, we need suspense. Today, we'll build a simplified version of suspense on the server to demonstrate its conceptual working and how it improves the rendering process from classical SSR to streaming and out-of-order streaming.
Before we go into that, though, I want to quickly touch on how we got to server components in the first place, how suspense ties into all that just to get everyone on the same page. Now, if we look at the history of web rendering, and the evolution of web rendering in the last 15 to 20 years, a lot of us will probably still remember the days when we were writing plain HTML, and then move to the server and like dynamically create that HTML with languages like PHP.
And that was one major drawback, which is, if you do a lot of work on the server, for example, to fetch data from different sources, that initial server response time can get really slow. And that's obviously really bad for user experience. So when we started introducing JavaScript to our applications to make them more dynamic, we also introduced concepts like AJAX, which allowed us, after the first load of the page is already done, to still go back to the server and fetch more stuff dynamically on demand.
And that's pretty cool, because it means we can offload a lot of that heavy lifting to the client and have a quick server response time, render some kind of loading state, and then do more work. And that's so convenient that we started doing it more and more, until we eventually got close to what we now call single page applications. And that's when all the frameworks that we know and love like React, like Vue, like Angular start to become really popular. To get the best of both worlds, though, we then really quickly reintroduced the concepts of server-side rendering and static-side generation in those frameworks. So that's essentially brought three major problems still with it. One was the bundle size. So ever since single page applications, JavaScript bundle sizes kept growing and growing and became a problem. With it, the second problem is the problem of hydration. So even if you server-side render your React application, it still needs to send all of that JavaScript to the client, and it needs to run all of the JavaScript to hydrate the whole application just in case a few parts of your app use client-side logic, like state, like effects, like event listeners, that kind of stuff. And hydration is slow. So hydration actually started becoming one of the major performance bottlenecks in modern web apps. The third problem is we're back on the server now. So again, we have the problem where if the server does a lot of work, and that work is slow, that initial server response time can get really sluggish really quickly.
So introduce React server components. Server components primarily try to do one thing. It allows us developers to tell the bundler which components are static and only ever need to be rendered once, and which ones are dynamic and need to be shipped to the client and need to be hydrated. So in a realistic normal application, this can actually massively reduce the amount of JavaScript you need to send to the client and the amount of hydration that's going on. So that's amazing, but on its own, server components don't actually do anything about that third problem. So that's where suspense comes in. And to kind of showcase how suspense tackles that problem, what we're going to do today is build our own version of suspense on the server from scratch. And I want to be really clear here, this is not how suspense is implemented in React. This is very deliberately a very simplified, very dumbed down version of it. The main point here is just to showcase how it works conceptually, and also to showcase how in the different stages it improves the experience. So we start with what I would call classical server-side rendering. Then we can see how we can improve that by introducing streaming, and then how we can further improve it by doing out-of-order streaming, which is what suspense on the server allows us to do.
3. Building a Movie App with Server-Side Rendering
Let's dive into the code by building a movie app called Notflix. We have different sections like the title, cast members, and similar movies. The components fetch their own data asynchronously, making them server components. In the classical way of server-side rendering, we loop through the children, execute them as server components, and render the HTML response.
All right. Enough talking. Let's actually get into the code. And for that, let's pretend we're writing a movie app, because that's what everyone seems to be doing these days when they're demoing stuff. You can see it's called Notflix, because it's definitely nothing like Netflix. I'm really bad at naming things. But the page that we're building is pretty straightforward. So we have the logo at the top, and we have a title section with the poster, and the title, and some meta information, year, genres, and a movie summary. Then we have a list of cast members. And at the bottom, we have another list of similar movies, because we definitely don't want our users to ever leave our application. We want them to binge-watch till the end of time.
If we look at the code, or more specifically, if we look at the components that we have, so here we have the title component, which is the poster and the summary, and we have the details component, which is the cast members list. And we can see that both of those components fetch their own data. It doesn't really matter for the purpose of this demo where that data comes from, if it's a third-party API or internally in our database. All that matters is it's asynchronous. It's doing some work that takes some time. The other thing that we can see is these components are essentially server components. Again, because for the purpose of this demo, we only really care about the server side of things. And what that means is they can't have any state, they can't have any effects, but they can be asynchronous functions, which we're taking advantage of by then loading the data inside. So this is important.
Now, let's start with what I call the classical way of server-side rendering. So if we look at the server code, we see it's a pretty standard express setup. And in our route, what we do is we render our application. And then we start to collect the HTML that we eventually want to return. And for the application itself, we just loop through all of the children. For all of the children, we assume they can be server components. So we execute them, but we wait, take the response, and then render that to HTML. Then we concatenate all of that. And once we collected all the HTML, we return that back to the client.
4. Improving Server-Side Rendering with Streaming
To improve the user experience, we introduce streaming, which allows us to start the response on the server, keep the connection open, and add to the response document as data becomes available. By using the write method provided by Express, we can write to the response stream instead of collecting all the HTML. Dealing with promises in sequence ensures that components are rendered in the correct order.
And once we collected all the HTML, we return that back to the client. And this is what that looks like. This uses that renderer, and we can see the problem that I described in the beginning by just clicking on one of the links. So you can see it takes a long time. And this is because the server has to do a lot of asynchronous work, but the browser has to wait for the server to have all of that work done to then return the HTML before the browser can navigate to the new page. So that's obviously awful from a user experience perspective.
So how can we improve it? Like I said, let's introduce streaming. So streaming has been around forever. It's basically with the introduction of the World Wide Web and HTML. Streaming allows you to start the response on the server, but then keep the connection open and just keep adding depending to that response document as stuff becomes available. And frameworks like Express and other server frameworks make it really easy for us to do that. In Express, on the response object, it exposes a write method. And that does exactly that. It writes to the response stream. So instead of collecting all the HTML, we actually now immediately want to write it to the stream. We don't need that anymore. That's right. And in the end, we have nothing collected anymore. So instead of sending, we just end the connection. That tells the browser that we're done and then it can close it.
Now, if we refresh the page now, we see great logo renders immediately. But you can also see the problem. You might have already spotted it before and are screaming at your screen right now. The page looks a bit wonky, like the sections are all out of order. And that kind of makes sense because we're still dealing with all of the promises of the asynchronous components in parallel, which doesn't work because in streaming, we can only ever append to the bottom of the document. So in this case, whatever component results first gets rendered first, which we have no control over. So in order to make this work, we actually need to deal with the promises in sequence. I'll just turn this into a sequential for loop instead of the promise on. And if we refresh the page now, again, the logo renders immediately and then it does exactly what we want. Each section comes in as it becomes available.
5. Introducing Suspense on the Server
Although the server-side rendering has improved, there is still no loading state or proper handling of suspended children. To address this, we introduce suspense on the server and build a suspense component with a fallback UI and children. We keep track of suspended children using a simple object with unique identifiers. In the renderer, we check the suspended object and loop through the entries to handle each suspended child.
So this is better what we started with than what we started with, but it's still not great, right? We're still mostly looking at a blank screen if the logo wasn't rendered at the top, but underneath the title section, we would literally be still staring at a blank screen. So we still don't really have a loading state or anything. So it isn't great.
So this is where suspense comes in, right? Suspense on the client, what we would do is we would just render a boundary, like I said in the beginning, and define our loading state that we want to render instead, right? So let's try to build that on the server.
First, we need to build that component because right now we don't have it. So let's create a new component, suspense. And like I said, we need a fallback UI and the children. I'm going to be lazy with types for the sake of time. And for now, I only want to return the fallback just to get us started. So let's import it here.
If we go back and refresh the page, exactly what we expected. But it renders immediately. So that's great. It's a good start. But it only renders the fallback. So how do we now deal with actually the suspended children?
Well, first of all, we kind of need to keep track of them, right? Whenever we suspend children, we need to put them somewhere. So let's introduce a simple object. It's called suspended. And let's introduce the ability to actually give each of the suspense instances a unique identifier. Again, they're all rendering on the server. So they all only ever run once. This is perfectly fine. And then we can use that unique identifier to actually store the suspended children. Cool.
And now in our renderer, what we can do is once we render the whole application, we can check for that. So if we look into the suspended object, we can check if it has any entries. And if it does, what we want to do is basically loop through the entries of the array. And then for each of those entries, essentially, what we want to do is the same thing that we've done before in the client-side renderer, in the classical server renderer. So one thing here, making sure that we're dealing with an array because children could be either an element or an array of elements. So I'm forcing it into an array, but then I'm doing the same thing that we've done in the very beginning.
6. Handling Content Swapping with JavaScript
To replace the loading state with the content once it's available, we need to execute asynchronous functions to collect and concatenate the content, delete the entry from the suspended object, and use JavaScript to handle the swapping of elements in the browser.
I'm collecting the content from all of them by executing the asynchronous functions and then just concatenating it at the end and writing it to the stream. Once we've done that, we can also delete the entry from the suspended object because we're done with it. And if we now refresh the page, we still see that loading state immediately. But then once the content is available, it starts popping into its existence, which is great. We're getting really close to what we actually want here.
The one thing that we do still want is it actually shouldn't just append the bottom of the document, right? Instead, we want it to swap out the loading state with its respective content once the content is there. So how can we do that? I just described the limitation of streaming is that you can only append to the bottom of the document. How can we replace a previously rendered element? Short answer is we can't. And that's, I know, really disappointing. But like most things in the world these days, the magic happens in JavaScript. So we do have to add a little bit of JavaScript to make this work the way we want it to.
7. Replacing Fallback Renderers with Content
To replace the fallback renderer with the content, we need to wrap the fallback in a diff and add an identifier using a data attribute. We can use the CSS trick of 'display: contents' to prevent the diff from affecting the layout. Next, we wrap the available content in a template tag to add it to the document without rendering. Finally, we use custom elements and a connected callback to swap the loading boundary with the content based on the identifier. This allows us to replace multiple fallback renderers.
And for that, there's actually a few things that we need to do. First, we actually need to be able to identify the fallback renderer, right? And if we want to replace it later with the content, we need to have an identifier. So let's wrap our fallback in a diff and add a some form of identifier to it. So we use a data attribute here. We don't want this diff to mess with your layout. So if you have like grid or flexbox layout above it, and there's a neat CSS trick that you can use. Display contents basically tells the browser to ignore this diff for any layout purposes, and it will just keep looking at the children to then do its grid or flex layout. So that's pretty cool. So we have the identifier.
Now in here, there's two things we want to do. One is we want to wrap our content once it's available. When we render it, we want to wrap it in a template tag. And the template tag basically allows us to still add it to the document, but the browser will not render it, it will just ignore it. And then we want to do the actual swapping. And there's a billion different ways how you can do the swapping of the content. In this demo, we are going to use web components, or more specifically, custom elements. So let's pretend we have a custom element called suspense content. And it has a target ID, which tells us what fallback we actually want to replace it. And now, we need the JavaScript for it. And because I'm somewhat running out of time, I'm going to copy paste this from my notes. Basically, what we want to do here is we create the custom element, we define the custom element suspense content. And custom elements allow us to define a connected callback. And that connected callback is called every time this tag is rendered. And in here, we're basically just checking for the previous sibling, which we know is the template tag. Then we get the suspense ID from our target ID attribute. And then we look for the responding suspense fallback based on the data attribute that we just added. And then all we do is really we swapping out the inner HTML of the loading boundary with what we have in the template tag. And if we now go back in here, we can see that it hopefully does exactly what we want. So it swaps out the loading state once the real data is available. And we're not limited to a single sub-end boundary.
8. Enhancing User Experience and Using the Platform
By wrapping each section in its own boundary, the user experience is significantly improved as each section can load independently. This approach also emphasizes the importance of using the platform's existing features and functionality, such as browser caching, to enhance performance. For further exploration, here are some recommended starting points on suspense, suspense on the server, and out of order streaming. You can also find the code and additional resources in the provided links. Thank you for watching and enjoy the rest of the conference!
So like on the client, we can throw in as many boundaries as we want. So let's just wrap each of the sections in its own boundary. But this, if we go back, it even further improves the user experience because now each of the sections can actually come in immediately when they're ready. They don't have to wait for all three sections to be there. And we can see that from a user experience perspective, that's a billion times better than what we started with.
And it also encourages us to use the platform more. I know it has become somewhat of a meme, use the platform, use the platform. But there's a reason why teams around Remix, Astro, Quick, and even React keep preaching that we should use the platform like God aka Tim Berners-Lee intended, right, because don't rebuild the wheel, use what's there. And a good example here is browser back and forward functionality, for example. Because the browser caches the whole server response, including everything that we streamed, if we go back and forward, it doesn't need to go back to the server and refetch all the movie information, all that. It's instant, because all of that is already in that cached server response. So I think that's pretty cool.
I'm running out of time here. So I hope I triggered a little bit of curiosity and interest in at least some of you. So if you want to get deeper into this topic, these are some really good starting points to read up on suspense, suspense on the server, out of order streaming, all that kind of stuff. If you want to take a closer look at the code that we've just written, this is the link to the repo, link to the slides, and also some links to my socials. I'm not very active there, but here we are. And that's pretty much it for me. I hope you really enjoy the rest of the conference. There's still a couple of talks and Q&A sessions coming up. And yeah, thank you all for watching.
Comments