Why I stopped hating React Server Components
A quick guide about the benefits of RSCs
When I first migrated to next js from react, I was frustrated by the idea of having components named differently as "client" or "server" components.
And a rule book of how to use what kind of component, like not having listeners and hooks in the server components, adding the top directive "use client" to explicitly mention client components, calling the DB directly from server component and all.
Like, I mean why do we need that at all?
As a react developer, I was habitual with the way of webapps operating completely on the client.
Apps that get built at client, js bundles execute at client and even data fetches happen from the client.
But when I got introduced with the metrices like build time (js bundle execution) at the client, SEO, FCP and all, and how they differ in the classic SPAs and Next js server components, I realised that something was really off working that way.
How do classic SPAs work?
At the classic Single Page Applications like React, everything happens on client like fetching the js bundle, building the app shell, executing the javascript and fetching the data.
In a typical client-rendered SPA, the application cannot start its own data fetching and rendering until enough JavaScript has been downloaded and executed to bootstrap the application.
When you visit the app for the first time, the server sends you a raw html with no content, but a useful script which looks something similar too:
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="/assets/index-a1b2c3.css">
<link rel="modulepreload" href="/assets/vendor-9f8e7d.js">
</head>
<body>
<div id="root"></div>
/* script executes on client to load the content */
<script type="module" src="/assets/index-4d5e6f.js"></script>
</body>
</html>
This HTML has nothing, no content, no elements in the body (except the root) to show at the client browser.
What it does have is a useful script tag with a JS file. The browser then loads this javascript file, compiles and executes it to render the app shell or what we call as layout of the app. The loaders or skeleton or static elements appear by now.
Means for a component like this:
import { useEffect, useState } from "react";
export default function App() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("http://api.data.com/user")
.then((res) => res.json())
.then((data) => setData(data))
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<h2>User info</h2>
<p>Loading...</p>
);
}
return (
<h2>User info</h2>
<p>{data.userName}</p>
);
}
Once the JS is loaded, compiled and executed at the browser, you will have the heading as "User info" and the "Loading..." text appear on your browser.
And once the data fetch is completed, you will the actual data in place replaced by the "Loading..." text or any kind of skeleton or spinner you have.
But we have got a major problem with this, because of these steps:
HTML sent to client
JS bundle sent to client
Browser loads, complies and executes JavaScript
Data fetching occurs
You see the actual data fetch occurs at the fourth step, once the JS is loaded, complied and executed on the client.
And if the JS bundles are huge or the browser network is slow, this may take a few seconds and until then user sees a blank screen on his browser which is enough for him to feel that the app is broken.
React Server Components
The way that the server components work is that you can have your async functions calling the database or api directly from the component without any useEffect hook.
Like the similar code block for data fetching above can be rewritten in server components as:
// app/dashboard/profile/page.tsx
async function getData() {
const res = await fetch("http://api.data.com/user");
return res.json();
}
export default async function ProfilePage() {
const data = await getData();
return (
<>
<h2>User info</h2>
<p>{data.userName}</p>
</>
);
}
And of course for the loading component, you can have a loading.tsx too:
// app/dashboard/profile/loading.tsx:
export default function Loading() {
return (
<>
<h2>My Dashboard</h2>
<p>Loading...</p>
</>
);
}
or a tag for a better suspense boundary.
Now the cool thing with server components is, they are built at server. The data fetching or processing occurs much faster there.
And there is way less Javascript bundle size to get loaded, compiled and executed on the client if you can mark the components as "client" or "server" correctly.
Also, your app is way more faster, has less FCP time (if you have your suspense boundaries placed correctly).
RSC + streaming lets the server start sending useful UI while slower parts are still resolving.
And since the server components, never get re-executed on the client, that means they shouldn't and can't be hydrated (Hydration is the process of taking server-rendered HTML and attaching React's client-side behavior to it so that it becomes interactive. It includes attaching listeners, events to your functions or hooks), and that's exactly the reason why you can't have listeners (like onClick) and hooks (like useState, useEffect) on your server components.
The biggest advantage of this is the size of the JS bundle that will now get executed at the client is less as we have less components to built and hydrated at the client (marked as client components).
