Mayank RSS

React Server Components: the Good, the Bad, and the Ugly

React Server Components bring server-exclusive capabilities to React. I’ve been using this new paradigm within Next.js 13 and 14, and what follows is my honest assessment of it1.

I debated not publishing this post because of the way the React community has historically handled criticism. It is only recently that I decided it is important to share my thoughts, especially after seeing that much of the existing criticism is either not well-documented or stems from unfamiliarity.

I’m writing this from the perspective of someone who cares heavily about user experience. I do also care about developer experience, but the user always comes first.

A quick refresher

I could just get into it, but I want to make sure we’re all on the same page first, since there’s a whole lot of misconceptions about React Server Components and React itself.

Until recently, React could be described as a UI rendering framework that lets you write reusable, composable components as JavaScript functions.

React is often used with a server framework2 (like Next.js, Remix, Express or Fastify) which controls the HTTP request/response lifecycle. This framework provides a convenient place for managing three important things:

  1. Routing: Defining which markup is associated with which URL path.
  2. Data fetching: Any logic that runs before “rendering” starts. This includes reading from the database, making API calls, user authentication, etc.
  3. Mutations: Processing user-initiated actions after initial load. This includes handling form submissions, exposing API endpoints, etc.

Fast forward to today, React is now able to take more control over each of these parts. It is no longer just a UI rendering framework. It is also sort of a blueprint for how a server framework should expose these important server-side features.

These new features were first introduced more than three years ago and are finally released in a “canary” version of React, which is considered “stable” for use primarily within the Next.js App Router.

Next.js, being a complete metaframework, also includes additional features like bundling, middleware, static generation, and more. In the future, more metaframeworks will incorporate React’s new features, but it will take some time because it requires tight integration at the bundler level.

React’s older features have been renamed to Client Components, and they can be used alongside new server features by adding the "use client" directive at the server-client boundary. Yes, the name is a bit confusing, as these client components can add client-side interactivity and also be prerendered on the server (same as before).

All caught up? Let’s dive in!

The good

First of all, this is cool:

export default async function Page() {
const stuff = await fetch(/* … */);
return <div>{stuff}</div>;
}

Server-side data-fetching and UI rendering in the same place is hella nice!

But this is not necessarily a new thing. That exact same code has worked in Preact (via Fresh) since 2022.

Even within old-school React, it has always been possible to fetch data on the server and render some UI using that data, all as part of the same request. Code below is simplified for brevity; you’ll usually want to use your framework’s designated data-fetching approach, like Remix loaders or Astro frontmatter.

const stuff = await fetch(/* … */);
ReactDOM.renderToString(<div>{stuff}</div>);

Within Next.js specifically, this used to only be possible at the route-level, which is fine, even preferable in most cases. Whereas now, React components can fetch their own data independently. This new component-level data-fetching capability does enable additional composability, but I don’t care for it (nor does the end user when they visit your page).

If you really think about it, the idea of “server-only components” itself is pretty straightforward to achieve: render the HTML only on the server, and never hydrate it on the client. That’s the whole premise behind islands architecture frameworks like Astro and Fresh, where everything is a server component by default and only the interactive bits get hydrated.

The bigger difference with React Server Components is what happens underneath. Server components are converted into an intermediate serializable format, which can be prerendered into HTML (same as before) and can also be sent over the wire for rendering on the client (this is new!).

But wait… isn’t HTML serializable, why not just send that over the wire? Yes, of course, that’s what we’ve been doing all along. But this additional step opens up some interesting possibilities:

In a way, this is like the opposite of islands architecture, where the “static” HTML parts can be thought of as server islands in a sea of mostly interactive components.

Slightly contrived example: you want to display a timestamp that you format using a fancy library. With server components, you can:

  1. format this timestamp on the server without bloating your client bundle with the fancy library.
  2. (some time later) revalidate this timestamp on the server and let React re-render the displayed string entirely on the client.

Previously, to achieve a similar result, you would have had to innerHTML a server-generated string, which is not always feasible or even advisable. So this is certainly an improvement.

Instead of treating the server as simply a place to retrieve data from, you can now retrieve the entire component tree from the server (for both initial load and future updates). This is more efficient and results in a better experience for both the developer and the user.

The almost good

With server actions, React now has an official RPC-like way of executing server-side code in response to user interaction (“mutations”). And it progressively enhances the built-in HTML <form> element so that it works without JavaScript. Cool! 👍

<form
action={async (formData) => {
"use server";
const email = formData.get("email");
await db.emails.insert({ email });
}}
>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
<button>Send me spam</button>
</form>

We’re going to gloss over the fact that React is overloading the built-in action attribute and changing the default method from “GET” to “POST”. I don’t like it, but whatever.

We’re also going to gloss over the weirdly-named "use server" directive, which is needed even if the action is already defined in a server component. It would be more apt to name it something like "use endpoint", since it’s basically syntactic sugar for an API endpoint. But again, whatever. I personally don’t really care if it’s even called "use potato". 🤷

The example above is still almost perfect. Everything is colocated, feels elegant, and works without JavaScript. Even if most of the business logic lives in a separate place, the colocation is especially nice because the form data object relies on the names of the form fields.

Most importantly, it avoids the need to wire up these pieces manually (which would involve some gross spaghetti code for making a fetch request to an endpoint and handling its response) or relying on a third-party library.

In a previous draft, I wrote all of this under “The Good” section, because it is legitimately a big improvement over the traditional approach. However, this quickly starts to get annoying when you want to handle advanced cases.

The bad

Let’s say you want to progressively enhance your form so that when the server action is processing, you prevent accidental resubmissions by disabling the button.

You’ll need to move the button into a different file because it uses useFormStatus (a client-side hook). Mildly annoying, but at least the rest of the form is still unchanged.

"use client";
export default function SubmitButton({ children }) {
const { pending } = useFormStatus();
return <button disabled={pending}>{children}</button>;
}

Now let’s say you also want to handle errors. Most forms need at least some basic error handling. In this example, you might want to show an error if the email is invalid or banned or something.

To use the error value returned by a server action, you’ll need to bring in useFormState (another client hook), which means the form needs to be moved into a client component and the action needs to be moved into a separate file.

"use server";
export default async function saveEmailAction(_, formData) {
const email = formData.get("email");
if (!isEmailValid(email)) return { error: "Bad email" };
await db.emails.insert({ email });
}
"use client";
const [formState, formAction] = useFormState(saveEmailAction);
<form action={formAction}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" aria-describedby="error" />
<SubmitButton>Send me spam</SubmitButton>
<p id="error">{formState?.error}</p>
</form>

Confusingly, even though this is now in a client component, the form still works without JavaScript! 👍

However:

The "use client" thing also starts to get unwieldy as your application grows more complex. It is possible to interleave server and client components, but it requires you to pass server components as props, rather than importing them from client components. This might be manageable for the first few levels from the top, but in practice, you will mostly rely on client components when deeper in the tree. That’s just the natural and convenient way of writing code.

Let’s revisit that timestamp example from above. What if you want to display the timestamp within a table which happens to be a client component nested within multiple levels of other client components? You could try to do some serious prop drilling or store the server component in a global store (or context) at the nearest server-client boundary. Realistically though, you might just keep using client components and incur the cost of sending date-fns to the browser.

Being locked out of using async components after a certain depth might not be such a bad thing. You can still reasonably build your application, since data-fetching should probably only happen at or near the route level. A similar limitation also exists in island frameworks, in that they do not allow importing static/server components within islands. It’s still disappointing though, because React took 3+ years and came up with the most complex solution, all the while promising that server and client components will interop seamlessly.

What may not be obvious is that this restriction has some serious implications. Inside a client component, all its dependencies (and its dependencies’ dependencies and so on) are also part of the client. This cascades down pretty quickly. A large number of components do not use features exclusive to the server or client, and they should probably stay on the server. But they will end up in the client bundle because they were imported into other client components. And you might not even realize this if these components do not use the "use client" directive themselves. To keep the client code small, you’ll have to be intentional and extra vigilant, because doing the “wrong” thing is easier. It’s like climbing out of a pit of failure.

The ugly

For some godforsaken reason, Next.js decided that it would be a good idea to “extend” the built-in fetch API within server components. They could have exposed a wrapper function, but that would make too much sense I guess.

And by “extend” I don’t just mean adding additional options to it. They’ve literally changed how fetch works! All requests are aggressively cached by default. Except if you’re accessing cookies, then it might not be cached. It’s a confusing, haphazard mess that makes very little sense. And you might not even realize what is and isn’t cached until you deploy to production, because the local dev server behaves differently.

To make matters worse, Next.js doesn’t let you access the request object. I don’t even have the words to articulate how ridiculous it is that they would hide this from you.

You also can’t set headers, cookies, status codes, redirect, etc. outside of middleware.

In the old Next.js Pages Router, none of these problems existed (except the middleware runtime limitation). Routes behaved predictably and there was a clear distinction between “static” and “dynamic” data. You had access to the request information and you could modify the response. You had way more control! That’s not to say the Pages Router didn’t come with its own weirdness, but it worked fine.

Note: I’m choosing to ignore the several bugs that exist in the Next.js App Router today (“stable” does not mean “bug-free”). I’m also not covering any experimental APIs that haven’t been released yet, because, well… they’re experimental. Combining the effects of any bug fixes and new (newer?) APIs, it’s quite possible that the experience might feel less frustrating in six months. I will update this section if that happens.

The uglier

Everything I’ve mentioned so far would be tolerable to varying degrees… if the bundle size got smaller.

In reality, bundles are getting larger.

Two years ago, Next.js 12 (with Pages Router) had a baseline bundle size of ~70KB compressed. Today, Next.js 14 (with App Router) starts at a baseline of 85-90KB3. After uncompressing, that’s almost 300KB of JavaScript that the browser needs to parse and execute, just to hydrate a “hello world” page.

To reiterate, this is the minimum cost that your users need to pay regardless of the size of your website. Concurrent features and selective hydration can help prioritize user events, but do not help with this baseline cost. They’re probably even contributing to this cost too, just by virtue of existing. Caching can help avoid the cost of redownloading in some cases4, but the browser still needs to parse and execute all that code.

If this does not sound like a big deal, consider that JavaScript can (and does) fail in many ways. And remember that the real world exists outside your fancy MacBook Pro and gigabit internet; most of your users are likely visiting your site on a much less powerful device.

Why does any of this matter for this post? Because reducing bundle size is touted as one of the main motivators for React Server Components.

Sure, server components themselves will not add any “more” JavaScript to the client bundle, but the base bundle is still there. And the base bundle now also needs to include code to handle how server components fit into client components.

Then there’s also the data duplication problem5. Remember, server components don’t render directly to HTML; they are first converted into an intermediate representation of the HTML (called the “RSC Payload”). So even though they will be prerendered on the server and sent as HTML, the intermediate payload will still also be sent alongside.

In practice, this means the entirety of your HTML will be duplicated at the end of the page inside script tags. The larger the page, the larger these script tags. All your tailwind classes? Oh yeah, they’re all duplicated. Server components may not add more code to the client bundle, but they will continue to add to this payload. This does not come free. The user’s device will need to download a larger document (which is less of a problem with compression and streaming but still) and also consume more memory.

Apparently this payload helps speed up client-side navigation, but I’m not convinced that that’s a strong enough reason. Many other frameworks have implemented this same thing with only HTML (see Fresh Partials). More importantly, I disagree with the very premise of client-side navigation. The vast majority of navigations on the web should be done using regular-ass links, which work more reliably, don’t throw away browser optimizations (BFCache), don’t cause accessibility issues, and can perform just as well (with prefetching). Using client-side navigation is a decision that should be thoughtfully made on a per-link basis. Building a whole paradigm around client-side navigations just feels wrong.

Closing thoughts

React is introducing some much-needed server primitives to the React world. Many of these capabilities are not necessarily new, but there is now a shared language and an idiomatic way of doing server things, which is a net positive. I’m cautiously optimistic about the new APIs, warts and all. And I’m glad to see React embracing a server-first mentality.

At the same time, React has done nothing (besides an abandoned experiment in 2019) to improve their pitiful client-side story. It is a legacy framework created to solve Facebook-scale problems with Facebook-scale resources, and as such is a bad fit for most use cases. Heading into 2024, here are some of the many things that React has yet to address:

These aren’t “unsolved” problems; these are invented problems7 that are a direct consequence of the way React is designed. In a world full of modern frameworks (Svelte, Solid, Preact8, Qwik, Vue, Marko) that do not have most of these issues, React is effectively technical debt.

I’d argue that adding server capabilities to React is much less important than fixing its many existing issues. There are lots of ways to write server-side logic without React Server Components, but it is impossible to avoid the atrocious mess that React creates on the client without replacing React altogether9.

Maybe you’re not concerned about any of the problems that I illustrated, or maybe you call it a sunk cost and continue on with your day. Hopefully, you can at least recognize that React and Next.js have a long way to go.

I do understand that open source projects are not obliged to solve anyone else’s problems, but React and Next.js are both built by/for huge companies (something they both use in their marketing), so I think all the criticism is warranted.

As a final note, I just want to emphasize that it is currently very difficult to draw a line between React and Next.js. Some (or many) of these new APIs might look and feel different within a framework that has more respect for standards (à la Remix). I’ll be sure to post an update when that happens.

Footnotes

  1. I’m only getting into the purely-technical bits today. A truly honest holistic assessment would also involve moral, cultural, and political points. Let’s save those for another day though; this blog post is already long enough.

  2. I’m going to pretend to forget about the whole phase where developers were client-side rendering their single-page applications. It was an incredibly absurd thing to do when React has supported server-side rendering for a whole decade now. Of course, a lot of it was React’s own fault for pushing the monstrous Create-React-App abstraction in their documentation for so long.

  3. For comparison, Remix starts at a baseline of around ~70KB, Nuxt at ~60KB, SvelteKit at ~30KB, and Fresh at ~10KB. Of course, bundle cost isn’t everything, and some frameworks have a higher per-component cost that might reach an “inflection point” on large enough pages.

  4. For caching to be effective, the framework’s base bundle needs to be split into a separate chunk, so that it can be fingerprinted independently of application code (which changes more frequently). This technique also assumes that the framework code will remain stable, which is not the case right now. Both React and Next.js are actively being developed, and you might want to regularly update them in order to take advantage of some fixes and improvements. And then there’s the fact that Next.js abstracts away the bundler, so you have less manual control over it.

  5. Data duplication is not a new problem. It is a natural result of writing components as isomorphic JavaScript which runs on the server for prerendering and then also gets sent to the client for hydration. Ryan Carniato has an excellent article on the challenges of efficient hydration that I highly recommend reading.

  6. There’s an ongoing effort to add an auto-memoizing compiler which will remove incidental complexity and improve performance. This will be great for existing codebases, but it feels like a band-aid. It’s adding more code (increasing payload size) to memoize more objects (increasing memory consumption), instead of fixing the actual issue. This is not a coincidence; the React team has consciously refused to acknowledge their flawed model in the face of clearly better solutions.

  7. React’s shortcomings are well-documented, so while I’m hardly making some new revelation here, I just want to make sure we’re all aware that React has made the deliberate decision of making their architecture “unnecessarily complex”. It doesn’t have to be this way.

  8. I repeatedly bring up Preact, because it really is quite impressive. It is living proof that you can keep the good parts of the React model intact, without getting bogged down with any of the extra fluff. They’ve even managed to tree-shake class components! Recently, they’ve also started diverging from React to avoid the papercuts of React state, and in quite a beautful way. The one big thing Preact is missing currently (that is present in React) is streaming capability, but they’re working on that too!

  9. Replacing React actually used to be realistically possible in older versions of Next.js, thanks to preact/compat. But this was before React and Next.js became more complex with concurrent features and whatnot. At one point, there was also an effort to make Preact work within Remix, but that goal is no longer being pursued.