Skip to main content

Command Palette

Search for a command to run...

Why are React Server Components actually beneficial? (full history)

Let's go back to the SPA days and see the evolution.

Updated
21 min read
Why are React Server Components actually beneficial? (full history)

Introduction

There is still a lot of confusions around React Server Components.

I want to take a step back and really understand the "why" behind React Server Components.

It's not a different way to render. But it's fundamentally different from the ground up. ✨

Before we start, thanks to MapleLeaf for proof reading this.

Traditional SPA and its problems

If you worked with React before, you've probably worked with a SPA (Single Page Application).

This is where the server sends an empty HTML shell and a bunch of JS that builds the UI.

// Server sends empty HTML
<div id="root"></div>
<script src="app.js"></script>

// Client has to:
1. Download JS
2. Parse & execute JS
3. Make API calls
4. Build DOM
5. Finally show content

The upside is that once this initial work is done, navigation feels instant. Clicking around the app is smooth since you do NOT need to reload the entire page. The app behaves more like native software on your machine.

However, there are trade-offs:

  • Until the JavaScript loads and runs, users see nothing but a blank page.

  • Search engines will struggle to index your app because they read raw HTML responses. They don't run JS.

  • That initial load can feel slow, especially on slower devices or networks. Because Client has to do all the work.

Traditional SSR

The idea with SSR is that instead of sending an empty HTML shell and a bunch of JS that builds the UI, the server pre-renders the HTML and sends it ready to go.

// Server runs React components and sends real HTML
<div id="root">
  <h1>Welcome John</h1>
  <div class="dashboard">
    <p>Your balance: $100</p>
    ...actual content...
  </div>
</div>
<script src="app.js"></script>

Upsides:

  • Faster First Paint: users see content immediately

  • Better SEO: crawlers see full content

  • Better performance on slow devices: less client-side work

How this works under the hood: When we "render" on the server, we turn the React components into HTML.

A simplified example:

app.get("/", (req, res) => {
  const html = ReactDOMServer.renderToString(<App initialData={someData} />);
  res.send(`
    <div id="root">${html}</div>
    <script>window.__INITIAL_DATA__ = ${JSON.stringify(someData)}</script>
    <script src="/client.js"></script>
  `);
});

Source code for render to string if you're interested (I tend to geek out too much myself lol): ReactDOMLegacyServerImpl.js#L35.

Problems with SSR

This may seem great at first, but it's not without its own problems.

Hydration cost

Without an interactive client, the page is just a static HTML shell. Which makes zero sense for most sites on the web. So we send not just the HTML, but also the JS to render that same HTML on the client.

The flow at a high level:

  1. Server renders components to HTML

  2. Client receives HTML

  3. Client downloads React + your components

  4. Client runs those same components to build React's internal tree

  5. Client "hydrates" by matching this tree to the existing HTML

"What do you mean by downloading React?"

When we say "downloading React", we mean downloading the React JavaScript library (react.js, react-dom.js). Your page needs:

  • The React library itself (core React algorithms, component logic, etc)

  • React DOM (browser-specific code for working with the DOM)

  • Your application code (your components, logic, etc)

The server sends those along as JS files:

<script src="/react.js"></script>
<script src="/react-dom.js"></script>
<script src="/your-app.js"></script>

On the client, it's still React. So we need the virtual DOM to understand how to hydrate.

It's a bit confusing, but let me break down the flow from having downloaded the JS to hydrating the page:

  1. React looks at the HTML on the page

  2. Runs your component code

    • This creates the virtual DOM

    • Virtual DOM is just "this component renders this component which renders this div"

    • Think React.createElement calls that create objects representing your UI

  3. "Hydration" begins:

    • React walks the virtual DOM tree and the real DOM tree in parallel

    • Matches them up node by node

    • "Ah, this virtual div matches this real div in the HTML"

    • Attaches event handlers to the real DOM nodes

    • Sets up state management

    • Makes everything interactive

You might go, "okay, so what's the problem again?"

The problem is that we "render" the FULL page on the server.

When we send the HTML and JS to the client, we still NEED to download and run React for the full page to make it interactive.

It's a lot of unnecessary work. 🤷

Server blocking

The server has to wait for ALL data before sending ANY HTML. If one component is slow, everything waits.

I say "waiting for all data", but it doesn't necessarily have to be database queries or fetches, it can be anything that takes time.

Mismatch between server and client

The HTML React creates on the server must perfectly match what React tries to create in the browser. Even if tiny things don't match (like timestamps or random numbers), React gets stuck trying to figure out where to attach all its event handlers and behaviors.

// Server renders:
function Component() {
  return <div>Hello {new Date().toString()}</div>
}

// Server HTML:
<div>Hello Mon Jan 21 2025 10:30:15</div>

// When client hydrates, it runs the component again:
<div>Hello Mon Jan 21 2025 10:30:16</div>

// React sees:
// - Server gave me HTML with "10:30:15"
// - My new render wants to show "10:30:16"
// - These don't match! Something's wrong!

React requires this match because:

  1. It needs to know which real DOM node is tied to which virtual DOM node

  2. If they don't match, React doesn't know if it should:

    • Trust the server HTML?

    • Trust its new render?

    • Which event handlers go where?

This is why React warns about hydration mismatches. It can't safely attach behaviors if it's not sure the structure matches what it expects.

An analogy would be trying to put a puzzle together:

  • The server gave you a completed puzzle (the HTML)

  • The client is trying to rebuild that same puzzle (the virtual DOM)

  • If the pieces don't match exactly, React doesn't know how to properly connect them

This can result in certain components not working or even worse, causing the entire page to fail (doesn't happen too often).

Dates are often the culprit for hydration mismatches. My friend Jacob has a great post on this: Solve React hydration errors in Remix/Next apps. ✍️

SSR Streaming

One big problem we had with SSR was that the server had to wait for ALL data before sending any HTML.

async function HomePage() {
  const userData = await fetchUser(); // 300ms
  const profileData = await fetchProfile(); // 500ms
  const postsData = await fetchPosts(); // 1000ms

  // Server can't send ANYTHING until all 1.8 seconds pass!
  return (
    <div>
      <Nav userData={userData} />
      <Profile profileData={profileData} />
      <Posts postsData={postsData} />
    </div>
  );
}

This sucks because:

  • User sees nothing for 1.8s

  • Even though Nav could be ready in 300ms!

  • Browser sits idle, could be downloading assets

  • Time to First Byte (TTFB) is terrible

"What if we could stream HTML? Send parts as they're ready?"

This led to renderToNodeStream(). It improved some situations, but in many cases, it wasn't enough and had the same problems.

Let's dive into some of the problems.

Needed all data before sending anything

In the example above, with streaming, the first bit of HTML will be sent after 300ms. Why? If data isn't ready for a component, it's not sent. You're probably thinking of suspense, we'll get to that. But here, there is NO way for us to have a "shell" and say "till the data is ready, show a loading state".

So we still have the problem of the server blocking. It's gonna make more sense with the next problem.

Could only stream at HTML tag boundaries

We can only stream the HTML in order. "At tag boundaries" means we can't stream the HTML in the middle of a component. It has to be in the order of the components.

For example, if the Profile's data is ready before the Nav's data, we can't send the Profile's HTML first. We have to wait for the Nav's data to be ready before we can send the Profile's HTML.

This really highlights a big problem here.

Couldn't prioritize important parts

Lastly, this ties to the previous problem, we can't prioritize important parts. What if I wanna say "focus on loading the Profile first, then the Nav, then the Posts"?

Of course, maybe we could do some Promises magic where we make sure the Profile is loaded first, but that's not the point. The point is there is no simple way to have the important parts load first.

React Suspense

Introduction

What if components could TELL React they're not ready yet?

function ProfilePage() {
  return (
    <div>
      {/* Ready immediately! */}
      <Nav />

      {/* Slow data... but why should this block Nav? */}
      <Profile />

      {/* Even slower data... but why should this block anything? */}
      <Comments />
    </div>
  );
}

We wanted to be able to say:

  • "Send Nav now"

  • "For Profile, show a placeholder until it's ready"

  • "For Comments, show a different placeholder until it's ready"

This is where Suspense comes in. We can show a nice loading shimmer while the data for a specific component is being fetched. 😁

function ProfilePage() {
  return (
    <div>
      {/* This renders immediately */}
      <Nav />

      {/* This tells React "hey, I might not be ready" */}
      {/* If I am not ready, show this fallback */}
      <Suspense fallback={<ProfileSkeleton />}>
        <Profile />
      </Suspense>

      {/* Same here */}
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments />
      </Suspense>
    </div>
  );
}

How Suspense works

Let's see what happens when a component "suspends":

function Profile() {
  // This is what happens under the hood when you fetch data
  // in a "suspense-enabled" way:
  const promise = fetchData();

  if (promise.status === "pending") {
    // This is what "suspending" actually means:
    // The component throws a promise!
    throw promise;
  }

  // If we get here, we have data
  // otherwise Suspense would have caught the promise
  return <div>{data.name}</div>;
}

When you wrap something in Suspense:

<Suspense fallback={<Loading />}>
  <Profile />
</Suspense>

Here is what happens:

  1. Tries to render Profile

  2. Catches the thrown promise

  3. Renders the fallback instead

  4. When promise resolves, tries rendering Profile again

Pseudo code of the catching part:

try {
  // Try to render Profile
  return <Profile />;
} catch (thrownValue) {
  if (thrownValue instanceof Promise) {
    // Aha! Component isn't ready yet
    // Show loading instead
    return <Loading />;
  } else {
    // Oops, real error, let it bubble up
    throw thrownValue;
  }
}

But here's where it gets really interesting with streaming SSR. When the server renders this, it:

  1. Immediately sends HTML for everything outside Suspense

  2. Sends the Loading fallback HTML for suspended parts

  3. Sets up "holes" in the HTML with special markers

  4. When data loads, streams HTML snippets that slot into these holes

  5. Also streams tiny <script> tags that know how to replace the fallbacks with the new content

What happens under the hood?

For this component:

function ProfilePage() {
  return (
    <Layout>
      <h1>Welcome!</h1>
      <Suspense fallback={<Spinner />}>
        <Profile />
      </Suspense>
    </Layout>
  );
}

Here's what streams to the browser in order:

  1. First chunk -> The shell and fallback:
<div id="layout">
  <h1>Welcome!</h1>
  <!-- Suspense boundary starts -->
  <div data-suspense-boundary="123">
    <div class="spinner">Loading...</div>
  </div>
</div>
  1. When Profile's data loads, React streams two things:
<!-- New content chunk -->
<template data-suspense-chunk="123">
  <div class="profile">Alice's Profile ...</div>
</template>

<!-- Inline script to perform the replacement -->
<script>
  document
    .querySelector('[data-suspense-boundary="123"]')
    .replaceChildren(
      document.querySelector('[data-suspense-chunk="123"]').content
    );
</script>

"What are these data attributes?" - That's just web development (very cool, I know).

The key pieces here:

  • Special markers (data-suspense-boundary) create "holes" that can be filled later

  • Content arrives in <template> tags so it doesn't render immediately

  • Tiny inline scripts swap out the fallback with real content

  • Each boundary has a unique ID so React knows what goes where

How Suspense Handles Things That Are Thrown

  1. If a Promise is thrown:

    • Suspense catches it

    • Shows the loading fallback

    • Waits for promise to resolve

    • Tries rendering again with the data

  2. If any other error is thrown:

    • Suspense ignores it

    • Error bubbles up to nearest Error Boundary

    • Error Boundary shows error UI

Streaming and Suspense

Let's see how Streaming + Suspense work together.

function ProfilePage() {
  return (
    <Layout>
      <NavBar />
      <Suspense fallback={<BigSpinner />}>
        <SlowProfile /> {/* Takes 2s */}
        <Suspense fallback={<PostsSkeleton />}>
          <SlowerPosts /> {/* Takes 5s */}
        </Suspense>
      </Suspense>
    </Layout>
  );
}

The streaming happens in this order:

  1. Immediately sends the shell:
<html>
  <div class="layout">
    <nav>...</nav>
    <!-- Suspense marker -->
    <div data-suspense="123">
      <div class="spinner">Loading...</div>
    </div>
  </div>
</html>
  1. After 2s, when SlowProfile resolves. The server streams the following:
<template id="profile-123">
  <div>Profile data...</div>
  <div data-suspense="456">
    <div class="posts-skeleton">Loading posts...</div>
  </div>
</template>
<script>
  // Replace spinner with profile content
  document
    .querySelector('[data-suspense="123"]')
    .replaceWith(document.getElementById("profile-123").content);
</script>
  1. After 5s, when SlowerPosts resolves. The server streams the following:
<template id="posts-456">
  <div>Posts data...</div>
</template>
<script>
  // Replace posts skeleton with posts
  document
    .querySelector('[data-suspense="456"]')
    .replaceWith(document.getElementById("posts-456").content);
</script>

This is much better than the old streaming we discussed. Users see the layout and nav instantly. Each part streams as it's ready.

It's important that the server keeps the connection open so it can stream the HTML as it's ready.

How does this work under the hood?

This is done through chunked transfer encoding.

When the server sends its initial response, it uses:

Transfer-Encoding: chunked

This tells the browser:

  1. "I'm going to send data in chunks"

  2. "Don't close the connection when you get the first chunk"

  3. "Each chunk will start with its size"

  4. "I'll send a zero-size chunk when I'm done"

A simplified example of what goes over the wire:

HTTP/1.1 200 OK
Content-Type: text/html
Transfer-Encoding: chunked

bb3
<html>
  <div class="layout">
    <div data-suspense="123">
      <div class="spinner">Loading...</div>
    </div>
  </div>

2ff
<template id="profile-123">
  <div>Profile data...</div>
</template>
<script>replace('123', 'profile-123');</script>

0

Chunked encoding is about a single response being sent in multiple pieces.

Core problem we still have

Introduction

I want to slow down a bit. RSCs can quickly get very confusing and complex.

Before we even talk about it, let's try to highlight the core problem we still have. Because, the UX is great right? 🤔 We can send HTML right away, show nice loading states, etc.

It's not bad. I agree. However, we could do much better. And even when I'm done with RSCs later, I'll think "beyond" RSCs. I hope you can follow me on this way of first principles thinking.

So, what's the core problem?

The problem!

The core problem is ALL the JavaScript that needs to be downloaded and executed on the client.

Consider a typical app:

function App() {
  return (
    <Layout>
      <Nav />
      <Suspense>
        <Profile /> // Rarely needs interactivity
        <Sidebar />
      </Suspense>
      <Suspense>
        <Comments /> // Needs to be interactive
      </Suspense>
    </Layout>
  );
}

Even with streaming, when client loads, we STILL need to:

  1. Download JS for EVERY component:

    • Layout code

    • Nav code

    • Profile code (even though it rarely needs interactivity!)

    • Sidebar code

    • Comments code

  2. For each component:

    • Create component instances (why do it if we know something is completely static!?)

    • Build virtual DOM nodes

    • Set up event handlers

    • Match with existing HTML

  3. Bundle sent from server to client includes:

    • React itself

    • All component code

    • All dependencies

    • State management code

    • Effect handlers

    • Event handlers

The key inefficiency:

  • Why download Profile code if it's just displaying data?

  • Why bundle Nav if it's just links?

  • Why ship React components at all for static content?

Even though streaming gives us better UX, we're still paying a heavy JavaScript cost for things that could just be HTML!

"What JS is actually being downloaded?"

You may wonder, what JS is actually being downloaded, even for completely static content.

Let's take this simple static component:

function Profile({ user }) {
  return (
    <div className="profile">
      <h2>{user.name}</h2>
      <span>{user.bio}</span>
    </div>
  );
}

Even though it's just displaying data, when using traditional SSR (with or without streaming) we still send:

  1. The component function itself

  2. Code for React to:

    • Create the component instance

    • Create and diff virtual DOM nodes

    • Handle component lifecycle

    • Set up the reconciliation process

  3. Code for handling props

  4. The component's module scope and dependencies

Why? Because React needs to "rebuild" the entire component tree on the client to:

  • Match it with the HTML

  • Be ready for potential updates (yes, we don't know what's going to happen)

  • Maintain the virtual DOM structure

This happens even if the component never updates and doesn't have any interactivity!

We're shipping all this JS "just in case" the component needs to be re-rendered or managed by React.

So when we say "download JS for every component", we're really downloading the entire React process for managing these components, even when they're effectively static HTML.

React Server Components

Introduction

What if, instead of sending component code to the client, we could just send the result?

Think about it:

// Traditional SSR - we send the code AND result
function Profile({ user }) {
  return <h1>{user.name}</h1>;
}
// Client needs: component code, React runtime, etc.

// RSC idea - what if we just sent:
<h1>Alice</h1>;
// Client needs: nothing! It's just HTML!

Okay. Works for static content. But what if we want interactivity?

What if we could mix these approaches? Have some components stay on the server, and others run on the client?

// Server Component - stays on server!
function Profile({ user }) {
  return <h1>{user.name}</h1>;
}

// Client Component - ships to client
"use client"
function LikeButton() {
  const [liked, setLiked] = useState(false);
  return <button onClick={() => setLiked(!liked)}>Like</button>;
}

This is the fundamental shift in thinking: Not all components need to run everywhere. 🚀

Server components stay on the server. Client components run on the client.

At a high level:

  • Let server components render to HTML

  • Create "slots" where client components will go

  • Tell React on the client exactly where to "inject" the interactive parts

A first example into what happens

Let's take this example:

function ServerComponent() {
  return (
    <div>
      <h1>Hello</h1>
      <ClientComponent />
    </div>
  );
}

"use client"
function ClientComponent() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}

What actually happens here?

React of course sends the initial HTML. But here is the special part: It also sends an RSC payload.

This payload is a special format that preserves the component boundaries.

What gets sent from the server:

// Server generates RSC payload AND assigns markers
[
  ["M", "ClientComponent.js"],
  ["S", "<div><h1>Hello</h1>"],
  ["J", "ClientComponent.js", {}, "B:0"],  // Server assigns marker
  ["S", "</div>"]
]

// Server also generates initial HTML with these markers
<div>
  <h1>Hello</h1>
  <template id="B:0"></template>
</div>

Before I continue, let's understand what the RSC payload is.

RSC payload

Each instruction (the single uppercased letters) is a different type of instruction.

There is actually some more of them.

Here are some common ones:

"M" - Module Reference
    - Tells client which client components it needs to load
    - Example: ["M", "Button.client.js"]

"S" - Server Component HTML
    - Static HTML from server components
    - Example: ["S", "<div>Hello</div>"]

"J" - Client Component Placeholder
    - Where to inject a client component
    - References a module, includes props, and has a marker
    - Example: ["J", "Button.client.js", {color: "blue"}, "B:0"]
    - Marker matches template id in HTML

"H" - Hint to preload module
    - Optimization to load the module with lower priority in background
    - Module will be needed, but it's not urgent
    - Example: ["H", "Modal.client.js"]

One of the things you'll notice is that it's actually in order (remember this, it'll be important for later) ⬇️

[
  ["M", "ClientComponent.js"],
  ["S", "<div><h1>Hello</h1>"],
  ["J", "ClientComponent.js", {}, "B:0"], // Server assigns marker
  ["S", "</div>"],
];

And also oh, the reason we have an empty object there (for J) is because we don't pass any props to the client component.

If you passed props from the server to the client component, you would see them in the object.

Another thing I found confusing personally is why we need M when J already has the module name. They're two different instructions!

M says "You will need this, so download it".

J says "Here is the module, and here is where to inject it".

Okay, what's happening on the client?

When the client receives this payload, it needs to:

  1. Build a VDOM, but not for everything!!

  2. Match components with their slots

  3. Set up hydration

The "Sparse" VDOM

React builds a VDOM, but not for everything like traditionally.

It reads through the RSC payload and only creates virtual DOM nodes when it sees:

  1. Parent elements that contain client components

  2. Client component placeholders

All other server HTML is ignored.

How React builds the VDOM:

// Our RSC payload from server:
[
  ["M", "ClientComponent.js"],
  ["S", "<div><h1>Hello</h1>"],
  ["J", "ClientComponent.js", {}, "B:0"],
  ["S", "</div>"]
]

// React builds this sparse VDOM:
{
  type: "div",      // Parent structure is needed
  children: [
    // Note: No VDOM node for <h1> - it's pure server HTML!
    {
      type: ClientComponent,  // Client component gets a VDOM node
      props: {},             // Props passed from server
      marker: "B:0"          // Links to the template in HTML
    }
  ]
}

Do you remember how I said the RSC payload is in order?

That's why React can match the VDOM's div to the correct HTML div.

From VDOM to Interactivity

Once React has built this sparse VDOM, it needs to:

  1. Load the client components

  2. Match them to their slots in the HTML

  3. Make them interactive

1. Loading Client Components

Remember that "M" instruction we got?

["M", "ClientComponent.js"];

React uses this to know what code to download. It's like React saying:

  1. "I see I need ClientComponent.js"

  2. "Let me download and parse that module"

  3. "Now I have the actual component code and can create instances"

2. Finding Component Slots

For each client component in our sparse VDOM, React:

  1. Looks at the marker (in our case "B:0")

  2. Finds the matching <template id="B:0"> in the HTML

  3. This tells React exactly where each client component should go

3. Making Components Interactive

Now, React can:

  1. Create an instance of ClientComponent

  2. Set up its state management (useState(0) in our example)

  3. Attach event handlers (onClick in our example)

  4. Replace the template with the live component

This is how React makes our client components interactive!

All RSC Payload instructions

I showed some of the common instructions.

There are more though. RSC payload can include these instructions (might not be up to date):

"S" - Server Component HTML
    - Static HTML from server components
    - Example: ["S", "<div>Hello</div>"]

"M" - Module Reference
    - Tells client which client components it needs to load
    - Example: ["M", "Button.client.js"]

"J" - Client Component Placeholder
    - Where to inject a client component
    - Example: ["J", "Button.client.js", {color: "blue"}, "B:0"]

"H" - Hint to preload module
    - Background preloading of modules
    - Example: ["H", "Modal.client.js"]

"E" - Error
    - Server-side error boundaries
    - Example: ["E", "Error message", "stack trace"]

"P" - Promise
    - For streaming data from server
    - Example: ["P", "chunk-123", Promise]

"B" - Bundle Reference
    - References to other JavaScript bundles
    - Example: ["B", "chunk-123.js"]

Understanding the client bundle

One common confusion is WHY do we need to mark client components with "use client"?

Before we dive into that, let's understand how the client bundle is created.

1. Creating the bundle

// Your source code has multiple client components:
"use client"
function Button() { ... }

"use client"
function Modal() { ... }

// Build process bundles them into something like:
clientBundle.js
{
  'Button.client.js': function Button() { ... },
  'Modal.client.js': function Modal() { ... },
  // ... other client components
}

2. Initial Page Load

  • The browser downloads this single clientBundle.js file

  • It contains all client components in a single file

  • Think of it like a "dictionary" of components

3. Using the RSC Payload

// When React sees this in RSC payload:
["M", "Button.client.js"];

// It looks up "Button.client.js" in the client bundle
const Button = clientBundle["Button.client.js"];

// Now it can create instances of Button
const buttonInstance = createElement(Button, props);

So when you see .client.js in the RSC payload, it's not really a file path in this case. It's more like a "key" to look up that component in the client bundle that's already been downloaded.

Common confusion

A common confusion is why we need to mark client components with "use client".

By default, all components are server components. This is intentional, we want to keep as much on the server as possible. The reason for that is to send as little JavaScript to the client as possible.

Whenever a bundler sees a "use client" directive, it knows that this component (or multiple components!) needs to be included in the client bundle.

That's why we need to mark client components with "use client". Otherwise, the bundler would not know which components to include in the client bundle.

I mentioned multiple components, let's look over it quickly:

// button.client.js
"use client";

export function Button() {
  return <button>Click me</button>;
}

export function IconButton() {
  return <button>🔥 Click me</button>;
}

// Using them in a server component
function ServerComponent() {
  return (
    <div>
      <Button />
      <IconButton />
    </div>
  );
}

If you export multiple client components, the bundler will know that it needs to include both of them in the client bundle. Not just that, the server will generate a unique slot marker for each of them.

Conclusion

React Server Components isn't a new rendering strategy. Rather, it's a new mental model for building React apps.

What's funny about this is that it's nothing new. This is heavily inspired by the Islands architecture.

Quote from Jason Miller, Creator of Preact:

The general idea of an “Islands” architecture is deceptively simple: render HTML pages on the server, and inject placeholders or slots around highly dynamic regions […] that can then be “hydrated” on the client into small self-contained widgets, reusing their server-rendered initial HTML.

I mean, it's literally what we do. We have "placeholders", build the sparse VDOM, and then hydrate the client components.

I guess one main difference is that with RSCs, this can be more fine grained.

If you think of a header component, maybe inside the header component you have a button that you want to be interactive. You don't need to turn the ENTIRE header component into a client component.

Beyond RSCs

When I think of the next step for RSCs, a cool thing would be something similar to client directives that Astro offers.

With client:visible for example, you can tell React to only hydrate the component when it's visible.

This can actually result in much less work, when you think about interactive pages on mobile for example. If it takes time for the user to scroll down, why do the work to hydrate the components that are not visible?

And if they are visible, users are likely not fast enough, so we should be able to hydrate the components on time and still have the experience feel good.

This is a conversation I've had with friends of what the next step for RSCs could be.

Honestly, I'm really happy though how React has evolved. VDOM was already a big pain and is the "core" to React.

But this removes a lot of unnecessary work. It's overall much less JavaScript we send from the server to the client.