DEV Community

loading...
Cover image for React - Server Components - Introduction and Initial Thoughts

React - Server Components - Introduction and Initial Thoughts

sidthesloth92 profile image Dinesh Balaji ・8 min read

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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..."
    }]
]
Enter fullscreen mode Exit fullscreen mode

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.
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>;
}
Enter fullscreen mode Exit fullscreen mode

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.
      ))}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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}>
      ))}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
      ))}
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

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..✨

Discussion (4)

Collapse
cristobalgvera profile image
Cristóbal Gajardo Vera

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?

Collapse
sidthesloth92 profile image
Dinesh Balaji Author

You are right mate. That was a typo. It's NextJS that I am comparing it to .. :)

Collapse
fxdpt profile image
Fxdpt

I think it's NestJS, nestjs.com/ it's a great backend framework.

Collapse
cristobalgvera profile image
Cristóbal Gajardo Vera

Yeah, great framework. I said that because concepts of pages and server & client components that he mention, but who knows haha

Forem Open with the Forem app