Just before Christmas, the React team gave an early Christmas present, Server Components a.k.a the zero bundle size components. Let's have a look at what they are, what they bring to the table and my thoughts.
Before we start, just want to let you know that the best resource for a deeper understanding would obviously be the RFC and the introduction video from the React team. I put this together for people who are light on time and to share my thoughts and understanding.
You can find the entire source for this post here. It's a fork of the actual demo repo from the React team. I just simplified the components for easier understanding. All kudos go to the React team.
With the introduction of the Server Components, the existing components have been renamed as Client components. In fact, we have three types now:
- Server Components
- Client Components
- Shared Components
Server Components
Let's look at some of the important features of the Server components.
Zero Bundle Size
They are zero bundle size because they are rendered on the server and only the rendered content is sent to the client. This means they do not add to your client JS bundle size. Let's look at an example,
// BlogPost.server.js - A Server component.
import { renderMarkDown } from '...'; // Server only dependency.
import {getBlogPost} from './blog/blog-api';
export default function BlogPost({blog}) {
const blog = getBlogPost(blog.id); // Get blog post from database directly.
return (
<>
<h1>{blog.title}</h1>
<p>{renderMarkdown(blog.markdown)}</p>
</>
);
}
Things to note here,
- All server components are suffixed with
server.{js,jsx,ts,tsx)
(At least for now). - Since they are not sent to the client, we can have code that accesses server resources like database, internal API's etc.
- Since all this happens in the server, the package you imported for rendering the markdown is not sent to the client, only the rendered content is sent. This is a significant reduction in the Client JS bundle size.
The component itself is straight forward, it fetches the data from the database and renders the content.
Render Format
If you have noticed, I have said that the content is rendered and not HTML
. This is because Server components are not rendered to HTML but rather to an intermediate format.
If the above component was the only component in your app, this is what would be returned from the server.
J0: [
["$", "h1", null, {
"children": "Blog 1"
}],
["$", "p", null, {
"children": "unt aut..."
}]
]
As you can see, only the rendered markdown is sent to the client and not the library itself.
Now you might be wondering why not HTML and this format? (I don't know the format's name.. 🙃). Let's see why in the next section.
State and difference from SSR
Let's look at a primary difference between Server components and SSR. SSR generates the HTML on the server which is then sent to the client for rendering by the browser. This means the content itself is static and you cannot have interactive markup.
However, since Server components use this intermediate format instead of HTML it allows them to have Client components that have interactive behaviour. Make no mistake, Server components themselves, cannot have state or event handlers, in other words, they cannot make use of useState
, useEffect
etc. However, they can have Client Components which in turn can have state.
Let's add a like button to the BlogPost
component that when clicked increments the number of likes for the blog post.
// BlogPost.server.js - A Server component.
import {getBlogPost} from './blog/blog-api';
import LikeButton from './LikeButton.client';
export default function BlogPost({blog}) {
const blog = getBlogPost(blog.id);
return (
<>
<h1>{blog.title}</h1>
<p>{blog.markdown}</p>
<LikeButton blog={blog} /> // A Client component.
</>
);
}
// LikeButton.client.js - A Client component.
import {likeBlogPost} from './blog/blog-api';
import React from 'react';
export default function LikeButton({blog}) {
const [likesCount, setLikesCount] = React.useState(blog.likes);
const handleClick = () => {
setLikesCount(prev => prev + 1);
};
return <span onClick={handleClick}>Likes: {blog.likes}</span>;
}
The BlogPost
Server component has a child component LikeButton
, which is a Client component that handles user interaction. The LikeButton
component is free to make use of useState
as it is a Client component and it also updates the local state on click.
Thus a Server component cannot have state itself, but it can make use of a Client component to maintain state and handle user interactions.
Note that the
LikeButton
component is only updating the local like count and not the count in the server. The point of this example was to show that Client components can have state and user interaction.
State Tree
To understand this, let's expand our example to have a BlogPostList
Server component that renders a list of blogs using our BlogPost
Server component.
// BlogPost.server.js - A Server component.
import {getBlogPosts} from './blog/blog-api';
import BlogPost from './BlogPost.server';
export default function BlogPostsList() {
const blogs = getBlogPosts();
return (
<>
{blogs.map((blog) => (
<BlogPost blog={blog} /> // Uses a server component.
))}
</>
);
}
Let's also update the LikeButton
component to replace the state variable for likes
with the likes
from the props. Let's also add a callback function that hits the server to update the likes
count of the particular blog post.
// LikeButton.client.js - A Client component.
import {likeBlogPost} from './blog/blog-api';
import React from 'react';
import {useLocation} from './LocationContext.client'; // Experimental API for POC.
export default function LikeButton({blog}) {
const [, setLocation] = useLocation();
const handleClick = async () => {
await likeBlogPost(blog.id);
setLocation((loc) => ({
...loc,
likes: blog.likes + 1,
}));
};
const likeBlogPost = async (id) => {
// Fetch call to update the blog post in the server.
};
return <span onClick={handleClick}>Likes: {blog.likes}</span>;
}
When you click on the like button, a call is made to the server to update the like count and then setLocation
is called. This is an experimental API provided by the React team to mimic a call to the server to fetch a unit of the UI. In this case, we are fetching the component tree for the current route. You can see in the network tab that a call was indeed made and all the components in the current route beginning from the root are returned.
The entire tree is rendered from the root and the parts that are updated are rendered, in this case, wherever likes
is displayed on the screen. Note that the call to update was made from the LikeButton
component however since the entire tree is updated the likes
count passed as a prop
to the LikeButton
is updated.
State of the Client components are maintained
Let's create a new Comment
component, that renders an input text field bound to a state variable. For simplicity, we will not implement the comment functionality.
// Comment.client.js - A Client component.
import React from 'react';
export default function Comment() {
const [comment, setComment] = React.useState('');
return (
<input
value={comment}
onChange={({target: {value}}) => setComment(value)}
/>
);
}
Type something in the comment text field of one of the blog posts. Now, click on any of the like buttons. You can see that even though the entire tree was rendered as a result of the like count update, the state of the Client components are preserved during such updates. As a result, whatever you typed in the comment box is intact and is not cleared. This is one of the biggest advantages of the Server components and a primary difference from traditional SSR.
Client Components
Client components are the components that we having been using all this while. But with server components in the mix, you need to remember one thing,
A Client component cannot import a server component however, it can render a server component passed in as children from a server component. Note that these props should be serializable, JSX is serializable and hence it can be passed in.
Not Possible
// FancyBlogPost.client.js - A Client component.
import React from 'react';
import BlogPost from './BlogPost.server';
export default function FancyBlogPost({ blog }) {
return (
<div className="fancyEffects">
<BlogPost blog={blog} /> // Not OK. Cannot import a Server component inside a Client component.
</div>
);
}
// BlogPostList.server.js - A Server component.
import {getBlogPosts} from './blog/blog-api';
import BlogPost from './BlogPost.server';
export default function BlogPostsList() {
const blogs = getBlogPosts();
return (
<>
{blogs.map((blog) => (
<FancyBlogPost blog={blog}>
))}
</>
);
}
The reasoning is quite simple, Client components are sent to the client. If it were to contain a Server component accessing some internal API, that would fail in the client since it will not have access. This is just one reason among many.
Instead, we can do the following.
Possible
// FancyBlogPost.client.js - A Client component.
export default function FancyBlogPost({ children }) {
return (
<div className="fancyEffects">
{ children }
</div>
);
}
// BlogPostList.server.js - A Server component.
export default function BlogPostsList() {
const blogs = getBlogPosts();
return (
<>
{blogs.map((blog) => (
<FancyBlogPost>
<BlogPost blog={blog} /> // Fine. Server component passed as childredn to a Client component.
</FancyBlogPost>
))}
</>
);
}
This is fine because from the perspective of the Client component the content is already rendered in the server as part of the parent Server component and only the rendered content is passed as a prop
to the Client component.
Other things to remember with respect to Client components,
- They end with the extension
*.client.{js,jsx,ts,tsx}
(At least for now) - They will be part of the client bundle and as such, you shouldn't be doing anything that you wouldn't want to be public. Eg: DB operations etc.
- They are free to use state and effect hooks.
- Use browser only API's.
Shared Components
Shared components can be rendered either as a Server component or as a Client component. This is determined by what component imports it. Since it can be used either as a Server or a Client component it has the most limitations.
- They don't have a specific suffix.
- They cannot have
state
. - They cannot make use of
useEffect
etc. - They cannot render Server components.
- They cannot use browser specific API's.
With all these limitations, these components can only be used to display content that is passed as a prop to it.
Thoughts and Conclusion
After reading this, if you are thinking that Server components are doing exactly what NextJS/SSR is doing. No. In the case of NextJS, the components are rendered in the server, yes, but eventually, the components are part of the client bundle and used for hydration. In addition, Server components allow for,
- Maintaining Client component state.
- A much granular integration of Client and Server components. For example, in NextJS, you are limited by pages to choose between client and server components.
- Code splitting is done based on file names and is now not an extra step to be done by the developers as an import.
Of course, there are parts that are being worked on like routing and stuff but I am genuinely excited by what Server components bring to the table. They provide the developers with the flexibility to choose between Client and Server components based on the requirements and get the best of both worlds.
Hope, I was able to explain some of the concepts in a way that was easy to grasp. Happy coding and see you in the next one.. :)
Follow me on Twitter or check out my website to know more about me..✨
Top comments (4)
Nice post, dude! BTW, I'm a little bit confused, at the end, when you mention "NestJS", do you want to mean "NextJS" instead, don't you?
You are right mate. That was a typo. It's NextJS that I am comparing it to .. :)
I think it's NestJS, nestjs.com/ it's a great backend framework.
Yeah, great framework. I said that because concepts of pages and server & client components that he mention, but who knows haha