19-Dec

React, Development and JavaScript

How to support React Server Components in your library

React server components is winning popularity, and it's crucial for component libraries to support it to stay relevant. How do you support it?

5 min read

·

By Marcus Haaland

·

December 19, 2023

React server components is winning popularity for its performance enhancements, and it's crucial for compont libraries to support it to stay relevant.

But here's the paradox: despite its importante, there's little guidance to find. Even in React's documentation, the information is hidden discretely in an arbitrary section. Here I will guide you through the process in transforming an existing component library into one which supports client- and server components.

CRASH

Screenshot av error message
This error message says that we are using client code in a component that is not marked as a client component.

This is what met me when importing a Heading from my component library onto my own page.

// Home.tsx
import { Heading } from "design-react";

export function Page() {
 <Heading>Home</Heading>
};

This was surprising to me. The Heading-component was a simple component with no client code, so it certainly shouldn't be nagging about client code running on the server.

What has gone wrong?

What exactly is the problem?

The rest of the component library looks like this: a simple Heading-component, as mentioned about. There's also a Button which uses useState, thus a client component. That's why I've marked the Button-file with the "use client"-directive, to ensure the component is treated as a client component.

// design-react

// Heading.tsx
export function Heading() {}

// Button.tsx
"use client";
export function Button() {}

// index.ts
export * from Heading;
export * from Button;

This looks all right, doesn't it?

The Heading-component does not need the "use client"-directive, as it is a server component. And if I'm using Button, it should it treated as a client component, and not cause an unpleasant error message.

Regardless of these steps, there are two reasons the error still occurs:

The first reason is that client code is running on the server. It is perhaps surprising, as on the home page we are only importing the server component Heading. The surprising reality is that Heading is in the same chunk as Button, and the whole chunk will try to be run.

Normally, there would have been no problem for Button to also run, since Button is marked to run on the client.

But then we come to the second cause of the error, which is how bundlers handle the code. They often move import statements to the top, which can push the "use client" directive down in the file. This directive must be at the top for the component to be recognized as client-specific. Improper placement causes both client and server components to run on the server, leading to crashes.

The solution

Thus we have two causes to the error:

  1. one single chunk causes client and server components to run in the same location, and
  2. the bundler moves the "use client"-directive, which wrongly categorizes the component

The solution to this is to split client and server components into separate chunks. And to move the "use client" directive to the top of the file.

Split the chunk

By splitting up the bundle, we are sure that the right chunk runs in the right environment. So we go from having one index.ts, where we import all files, to splitting the import into clientComponents.ts and serverComponents.ts.

// clientComponents.ts
export * from Button;

// serverComponents.ts
export * from Heading;

We also have to tell the bundler that we are going from one entry to having two entries. I used Vite/Rollup as a bundler, but equivalent configs exist in other bundlers as well:

// vite.config.ts

export default defineConfig({
  build: {
    lib: {
      entry: [
        path.resolve(__dirname, "clientComponents.tsx"),
        path.resolve(__dirname, "serverComponents.tsx")
      ],
      formats: ["es"],
      fileName: (format, name) => {
        return `${name}.js`;
      }
    },
  },
});

We also have to update exports in package.json, so consumers can import the components from the correct entry:

// package.json

"name": "design-react",
"exports": {
    "./clientComponents": {
      "import": "./dist/clientComponents.js"
    },
    "./serverComponents": {
      "import": "./dist/serverComponents.js"
    }
  },
  "files": [
    "dist"
  ]

We have now split the components into different chunks, but this requires an adjustment to support the correct export of types.

Note about TypeScript

TypeScript generates a separate type file for each chunk: clientComponents.d.ts and serverComponents.d.ts. As package.json only supports referencing one type file, we have to solve types for the chunks in another way.

The solution is to use typesVersions. The field's intension is to set differet type files for different TypeScript versions. But we can also use it to point where to find the type file for a specific module:

// package.json
"typesVersions": {
  "*": {
    "clientComponents": [
      "./dist/types/clientComponents.d.ts"
    ],
    "serverComponents": [
      "./dist/types/serverComponents.d.ts"
    ]
  }
},

We have now split up the chunks and adjusted the types. As a consumer of the package, I can now import from the correct file path to avoid being assaulted by an error message:

// Home.tsx
import { Heading } from "design-react/serverComponents";

But we have only solved half the problem. If you use a button from clientComponents, the page will crash. We still need to fix the "use client" directive.

Put the directive at the top

To add the "use client" directive at the top of a file, we can use a tool available in many bundlers: banner.

Banners are often used to write about lisencing, but will in our case be used to add the "use client" directive at the top of the chunk. This is the same method the popular component library Chakra UI uses to mark its components as client components.

Here we update our bundle config. We only add the "use client" directive to the clientComponents-chunk:

// vite.config.ts

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        banner: (chunkInfo) => {
          if (chunkInfo.name === "clientComponents") {
            return "'use client';";
          }
          return "";
        }
      }
    }
  },
});

Nå kan vi også bruke Button-komponenten uten verken et ekstra “use client”-direktiv eller at siden kræsjer.

Now we can use the Button component without either an additional "use client" directive or the page crashing.

// Home.tsx
import { Heading } from "design-react/serverComponents";
import { Button } from "design-react/clientComponents";

export function Page() {
  <>
    <Heading>Home</Heading>
    <Button>Boop</Button>
  </>
};

Conclusion

We have now solved two main challenges to support React Server Components in a library. First, we discovered how to split components into separate chunks for client and server, so we run the right code in the right environment. Second, we learned how using the "banner" tool in bundlers can ensure that the "use client" directive remains at the top of the file, which is critical for it to behave as expected. By following these steps, you can use React Server Components for better performance.