July 19, 2023 (about 1 month ago)
Everything I wish I knew before moving 50,000 lines of code to React Server Components
React Server Components are a lot. We recently rethought our docs and rebranded Mux and, while we were at it, moved all of mux.com and docs.mux.com over to Server Components. So… believe me. I know. I also know that it’s possible and not that scary and probably worth it.
Let me show you why by answering the following questions: Why do Server Components matter, and what are they good for? What are they not good for? How do you use them, how do you incrementally adopt them, and what kind of advanced patterns should you use to keep them under control? By the end of all this, you should have a pretty good idea of whether you should use React Server Components and how to use them effectively.
One great way to understand React Server Components is to understand what problem they’re solving. So let’s start there.
Long ago, in days of yore, we generated websites on servers using tech like PHP. This was great for fetching data by using secrets and doing CPU-heavy work on big computers so that clients could just get a nice, light HTML page, personalized to them.
Then, we started wondering: What if we wanted faster responses and more interactivity? Every time a user takes an action, do we really want to send cookies back to the server and make the server generate a whole new page? What if we made the client do that work instead? We can just send all the rendering code to the client as JavaScript!
This was called client-side rendering (CSR) or single-page applications (SPA) and was widely considered a bad move. Sure, it’s simple, which is worth a lot! In fact, for a long time, the React team recommended it as the default approach with their tool, create-react-app. And for frequently changing, highly interactive pages like a dashboard, it’s probably enough. But what if you want a search engine to read your page, and that search engine doesn’t execute JavaScript? What if you need to keep secrets on a server? What if your users’ devices are low-powered or have poor connections (as so many do)?
This is where server-side rendering (SSR) and static site generation (SSG) came in. Tools like Next.js and Gatsby used SSR and SSG to generate the pages on the server and send them to the client as HTML and JavaScript. The best of both worlds. The client can immediately show that HTML so the user has something to look at. Then, once the JS loads, the site becomes nice and interactive. Bonus: search engines can read that HTML, which is cool.
This is actually quite good! But there are still a few problems to solve. First: most SSR/SSG approaches send all the JavaScript used to generate the page to the client, where the client then runs it all again and marries that HTML with the JavaScript that just booted up. (This marriage, by the way, is called hydration — a term you’ll see a lot in this neck of the woods.) Do we really need to send and run all that JavaScript? Do we really need to duplicate all of the rendering work just to hydrate?
Second, what if that server-side render takes a long time? Maybe it runs a lot of code, maybe it’s stuck waiting for a slow database call. Then the user’s stuck waiting. Bummer.
This is where React Server Components come in.
React Server Components (RSCs) are, unsurprisingly, React components that run on the server instead of on the client. The “what” isn’t nearly as interesting as the “why,” though. Why do we want RSCs? Well, frameworks that support RSCs have two big advantages over SSR.
First, frameworks that support RSCs give us a way to define where our code runs: what needs to run only on the server (like in the good ol' PHP days) and what should run on the client (like SSR). These are called Server Components and Client Components, respectively. Because we can be explicit about where our code runs, we can send less JavaScript to the client, leading to smaller bundle sizes and less work during hydration.
The second advantage of RSC-driven frameworks: Server Components can fetch data directly from within the component. When that fetch is complete, Server Components can stream that data to the client.
This new data-fetching story changes things in two ways. First, fetching data in React is way easier to think about now. Any Server Component can just… fetch data directly using a node library or using the fetch function we all know and love. Your user component can fetch user data, your movie component can fetch movie data, and so on and so forth. No more using a library or using useEffect to manage complex loading states (react-query I still love you), and no more fetching a bunch of data at the page level with getServerSideProps and then drilling it down to the component that needs it.
Second, it solves the problem we talked about earlier. Slow database call? No need to wait; we’ll just send that slow component to the client when it’s ready. Your users can enjoy the rest of the site in the meantime.
Bonus round: What if you need to fetch data on the server in response to a user’s action on the client (like a form submission)? We have a way to do that, too. The client can send data to the server, and the server can do its fetching or whatever, and stream the response back to the client just like it streamed that initial data. This two-way communication isn't technically React Server Components — this is React Actions — but it’s built on the same foundation and is closely related. We’re not going to talk much about React Actions here, though. Gotta save something for the next blog post.
Up until now, I’ve been painting a pretty rosy picture. If RSCs are so much better than CSR and SSR, why wouldn’t you use them? I was wondering the same thing, and I learned the hard way — as the title of this post suggests — that there is indeed a catch. A few, actually. Here are the three things we spent the most time on when migrating to React Server Components.
Turns out that, as of right now, CSS-in-JS doesn’t work in Server Components. This one hurt. Moving from styled-components to Tailwind CSS was probably the biggest part of our RSC conversion, although we thought it was worth the trouble.
So, if you went all-in on CSS-in-JS, you’ve got some work to do. At least it’s a great opportunity to migrate to something better, right?
You can access React Context only in Client Components. If you want to share data between Server Components without using props, you’ll probably have to use plain ol' modules.
And here’s the kicker: If you want some sort of data to be limited to a subtree of your React application, there is no great mechanism for doing that in Server Components. (If I'm wrong, please correct me. I really miss this.)
On our docs site, this wasn’t too big of a problem. The places where we used React Context heavily were also the places that were highly interactive and needed to be shipped to the client anyway. Our search experience, for example, shares state like queryString and isOpen throughout the component tree.
On our marketing site, though, this really got us. Our marketing site has areas that share a theme. For example, in the screenshot below, each component in our pre-footer needs to understand that it is on a green background so it knows to use the dark green border. Normally, I would’ve reached for Context to share that theme state, but since these are largely static components that are ideal candidates for Server Components, Context wasn’t an option. We worked around this by leaning hard on CSS custom properties (which is probably better, since this is a styling concern, not a data concern). But other developers may not be so lucky.
Fundamentally, RSCs give you more flexibility about where your code runs and what your data fetching looks like. With flexibility comes complexity. No tool can completely paint over this complexity, so at some point, you’re going to have to understand it and confront it and communicate it to other developers.
Every time a new developer picked up our codebase, the questions came up: “What’s running on the server? What’s running on the client?” Every PR had feedback regarding something accidentally/unnecessarily shipped to the client. I frequently added console logs to my code to see if the server or the client would do the logging. And don’t even get me started on the complexity of caching.
This has gotten better with practice and with reliable patterns. So let’s talk about that. How do we use React Server Components? How do we suggest migrating incrementally? How do we do tricky things without creating an illegible hairball of spaghetti code?
You haven’t been scared away yet? Think the pros outweigh the cons? Great! Let’s dive in, starting with the basics.
As of the time of writing, the only production-ready implementation of RSCs is Next.js 13’s new app directory. You could roll your own RSC framework, but if you’re the kind of developer who does that, you’re probably not reading my blog post. Anyway, some notes here might be a bit specific to Next.js.
The mental model of Server Components may be complicated, but the syntax is blissfully simple. By default, any component you write in Next.js 13’s new app directory will be a Server Component. In other words, by default, none of your page’s code is getting sent to the client.
Add async to that Server Component and you can just… fetch data! Here’s what that might look like:
There’s one last ingredient to really unlock the power of RSCs. If you don’t want to be stuck waiting for one slow data fetch, you can wrap your Server Components in React.Suspense. React will show the client a loading fallback, and when the server is done with its data fetching, it will stream the result to the client. The client can then replace the loading fallback with the full component.
In the example below, the client will see “loading comments” and “loading related videos.” When the server is done fetching the comments, it will render the <Comments /> component and stream the rendered component to the client; likewise with related videos.
Embracing React.Suspense has advantages beyond streaming data when it’s ready. React can also take advantage of Suspense boundaries to prioritize hydrating certain parts of an app in response to user interaction. This is called selective hydration, and is probably a topic better left to the experts.
Now let’s say you have some code that needs to run on the client. For example, maybe you have an onClick listener, or you’re reacting to data stored in useState.
A component gets shipped in one of two ways. The first: By adding “use client” at the top of a file, that module will be shipped to the client so it can respond to user interaction.
The second way a component gets shipped to the client is if it’s imported by a Client Component. In other words, if you mark a component with “use client”, not only will that component be shipped to the client, but all the components it imports will also be shipped to the client.
(Does this mean that a Server Component can’t be a child of a Client Component? No, but it’s a little complicated. More on that later.)
If it’s helpful, you can think of it this way: “use client” is telling your bundler that this is the client/server boundary. If that’s not helpful, well, ignore the last sentence.
We can leverage this second way to solve a common problem. Let’s say you want to use a library that doesn’t yet support React Server Components, so it doesn’t have “use client” directives. If you want to make sure that library ships to the client, import it from a Client Component, and it will be shipped to the client too.
Let’s take a step back and summarize.
Server Components are the brave new React world. They’re great for fetching data and running expensive code that you don’t want or need to send to the client: rendering the text of a blog post, for example, or syntax-highlighting a code block. When convenient, you should leave your code as Server Components to avoid bloating your client bundle.
Client Components are the React you know and love. They can be server-side rendered, and they’re sent to the client to be hydrated and executed. Client Components are great when you want to react to user input or change state over time.
If your whole app was made of Client Components, it would work just like it used to with yesterday’s SSR frameworks. So don’t feel pressured to convert your whole app to Server Components all at once! Adopt them incrementally in places that would stand to gain the most. And… speaking of incremental adoption…
This is the part of the show where folks tend to say, “Neat! But this seems like a lot of work, and I don’t have time to rewrite my whole codebase.” Well, I’m here to tell you that you don’t need to. Here’s the three-step playbook we used to bring most of our code to Server Components:
- Add the “use client” directive to the root of your app
- Move the directive as low in the rendering tree as you can
- Adopt advanced patterns when performance issues arise
Let’s walk through that.
Yup. That’s it. If you’re in Next.js 13, go to your top-level page.tsx and plop in a “use client” at the top. Your page works just like it used to, except now you’re ready to take on the world of Server Components!
Got any server-side data fetching? We can’t do that from a Client Component, so we’re going to add a Server Component. Let’s add it as a parent of the Client Component. That Server Component will perform the data fetching and pass it into our page. Here’s what that will look like:
Next, take that “use client” directive and move it from that top-level component into each of its children. In our example, we’ll be moving it from our <Client /> component into our <Player /> and <Title /> components.
And repeat! Except… because neither <Player /> nor <Title /> have children into which we can push the “use client” directive, let’s remove it!
<Title /> has no issues, because <Title /> doesn’t require any client-side code and can be shipped as pure HTML. Meanwhile, <Player /> throws an error.
Great. That’s as low as we can go. Let’s restore “use client” to the <Player /> component to address that error and call it a day.
See? That wasn’t too bad. We’ve moved our app to Server Components. Now, as we add new components and refactor old ones, we can write with Server Components in mind. And, we’ve saved a bit of bundle size by not shipping <Title /> !
Steps 1 and 2 should be enough for most cases. But if you’re noticing performance issues, there are still some wins you can squeeze out of your RSC conversion.
For example, when we migrated our docs site to RSCs, we leaned on two patterns to unlock deeper gains. The first was wrapping key Server Components in Suspense to enable streaming of slow data fetches (as demonstrated earlier). Our whole app is statically generated except for the changelog sidebar, which comes from a CMS. By wrapping that sidebar in Suspense, the rest of the app doesn’t have to wait for the CMS fetch to resolve. Beyond that, we leveraged Next.js 13’s loading.js convention, which uses Suspense/streaming under the hood.
The second optimization we applied was creatively rearranging Client and Server Components to ensure that large libraries, like our syntax highlighting, Prism, stayed on the server. And speaking of creatively rearranging Client and Server Components…
We established earlier that any component imported from a Client Component would itself become a Client Component. So… how do you make a Server Component a child of a Client Component? Long story short, pass Server Components as children or props instead of importing them. The Server Component will be rendered on the server, serialized, and sent to your Client Component.
This, imo, is the hardest thing to wrap your head around in this whole RSC mess. It gets easier with practice. Let’s check out some examples, starting with the wrong way.
By importing ServerComponent in a Client Component, we shipped ServerComponent to the client. Oh no! To do this properly, we have to go up a level to the nearest Server Component — in this case, ServerPage — and do our work there.
Nope! But here’s a pattern we use a lot, when we want part of our component’s functionality to stay on the server. Let’s say we’re making a <CodeBlock /> component. We might want the syntax highlighting to stay on the server so we don’t have to ship that large library, but we might also want some client functionality so that the user can switch between multiple code examples. First, we break the component into two halves: CodeBlock.server.js and CodeBlock.client.js. The former imports the latter. (The names could be anything; we use .server and .client just to keep things straight.)
Now that we have those two components, let’s make them easy to consume with a delightful file structure. Let’s put those two files in a folder called CodeBlock and add an index.js file that looks like this:
Now, any consumer can import CodeBlock from ‘components/CodeBlock.js’ and the Client and Server Components remain transparent.
Honestly, at first, we just added console.log to our code during development and checked to see if that log came out of the server or web browser. This was enough to begin with, but we did eventually find a better way.
If you want to be extra sure that your Server Component will never get included in a bundle, you can import the server-only package. This is extra handy if you want to make sure a large library or a secret key doesn’t end up where it shouldn’t. (Though if you’re using Next.js, it will protect you from accidentally shipping your environment variables.)
Using server-only also had another subtle but meaningful benefit for us: legibility and maintainability. Maintainers who see server-only at the top of a file know exactly where that file is running without having to keep a complete mental model of the component tree.
At the end of the day, React Server Components don’t come for free. It’s not just those gotchas surrounding CSS-in-JS or React Context. It’s also the added complexity: understanding what’s running on the server and what’s running on the client, understanding hydration, incurring infrastructure costs, and of course, managing the code complexity (especially when mixing Client and Server Components). Every facet of complexity adds another surface for bugs to sneak in and for code to become less maintainable. Frameworks reduce this complexity, but they don’t eliminate it.
When deciding whether to adopt RSCs, weigh these costs against the benefits — like smaller bundle sizes and faster execution, which can be critical to SEO. Or advanced data loading patterns that can be used to optimize complex data-heavy sites. Jeff Escalante, trying to answer the same question in their Reactathon talk, nailed it with this diagram:
If your team is ready to take on the mental overhead and the performance benefits are worthwhile, then RSCs might just be for you.
Written By
Pretends he knows more about coffee than he does. Happier when he's outside. Thinks the web is pretty neat.
Leave your wallet
where it is
No credit card required to get started.
from Hacker News https://ift.tt/8jSuMHm
No comments:
Post a Comment
Note: Only a member of this blog may post a comment.