8 min read

React Server Components are pretty cool. It was a tough concept to understand at first, since not many guides explained how it all works internally, but eventually I got the hang of it.

But, as I developed reusable components, I found out that I had to create separate implementations (and therefore add separate exports) for React Server Components and Client Components. That wasn't ideal, because I wanted to put all of the code in one place and have it behave differently depending on what environment it was imported from.

So, I learned a lot of new information about the inner workings of RSC, SSR and CSR, figured out how to differentiate between RSC and SSR environments, and "invented" a solution for this problem, — "Hybrid" Shared Components.

With "Hybrid" Shared Components there's no need to create both /CodeBlock/server and /CodeBlock/client exports. You can import the component from one single path, and have it contain different behaviour, depending on whether they're imported from a Server Component (RSC) or a Client Component ("use client").

React Environments

Put simply, the compiler/bundler or whatever creates 3 separate environments/bundles:

  • One for React Server Components (RSC, runs on the server).
  • One for Server-Side Rendering (SSR, runs on the server).
  • One for Client-Side Rendering (CSR, runs in the browser).

If you're already familiar with inner workings of RSC/SSR/CSR, you can skip the basic stuff, and go straight to the Telling the environments apart chapter.

React Server Components

<ServerLayout>
  <ServerComponent data={data} />

  <ClientComponent data={data} staticData={<ServerMarkup />} />
</ServerLayout>

In the environment of React Server Components, all references to client components (ones with the "use client" directive at the top of the file) are replaced with special placeholders. This transformation applies only to React Function Components, — that's why an error is thrown when you try import anything else from a "use client" module in a server component.

<ServerLayout>
  <ServerComponent data={data} />

  {{
    $$typeof: Symbol.for("react.transitional.element"),
    type: ClientComponent,
    props: { data: data, staticData: <ServerMarkup /> },
  }}
</ServerLayout>

All the server components in the tree would get pre-rendered, and stored as a so-called RSC payload, that will be reused across all page requests (assuming caching is used). The client components are then simply inserted into this pre-rendered template.

<div class="server-layout">
  <div class="server-component">Data</div>

  {{
    $$typeof: Symbol.for("react.transitional.element"),
    type: ClientComponent,
    props: { data: data, staticData: <div class="server-markup"></div> },
  }}
</div>

Server-Side Rendering

The next step is rendering the page's initial HTML through server-side rendering. At this point the RSC payload was already generated, so it gets used to render that part of tree. Any server components that were passed as props to the client component are replaced with the pre-rendered HTML. Now all that React needs to be aware of is the client components' code.

<ClientComponent data={data} staticData={serverMarkupRscPayload} />

The server runs and renders the components as usual, with a few differences:

  • useEffect and useLayoutEffect's callbacks aren't executed. The server renders the tree only once, preventing these hooks from firing. All "state-ful" hooks work as expected though, since they already have data that they can return.

  • Browser API cannot be used during the first render. Properties such as window, document, location, navigator and etc. simply won't exist, and the code will fail with the message "TypeError: window is not defined".

The client component tree is rendered to HTML and is then sent to the client's browser, allowing the browser to display what the page looks like, without fully downloading and running the client code itself yet. When the client code is finally downloaded, the inserted static HTML is "hydrated" with real, working client components, providing interactivity to the page.

Client-Side Rendering

Just like server-side rendering, the client-side doesn't need to concern itself with the static RSC payload either. Server-rendering provides it with the initial render's HTML, and the client-side then attaches the client code to those elements.

<ClientComponent data={data} staticData={serverMarkupRscPayload} />

The rest works as you would expect it to work. As you interact with the page, the client code is run, and the page is updated as needed.

Telling the environments apart

It's easy to tell if the code is executing on the server or on the client, since the window variable only exists on the client. But telling apart RSC from SSR is a bit more complicated. I managed to create my own solution, but it turned out to be suboptimal, as it didn't support tree-shaking. A better solution already existed as an NPM package rsc-env. It uses "react-server" conditional exports to provide different constants for RSC and SSR environments.

import { rsc } from "rsc-env";

if (typeof window === "undefined") {
  if (rsc) {
    // React Server Component
  } else {
    // Server-Side Rendering
  }
} else {
  // Client-Side Rendering
}
The suboptimal solution I came up with

For some reason, I couldn't find much information on the web on how to tell apart RSC from SSR, so I had to investigate myself, and I found a solution. React in RSC's environment doesn't export the useEffect hook.

import React from "react";

if (typeof window === "undefined") {
  if (!("useEffect" in React)) {
    // React Server Component
  } else {
    // Server-Side Rendering
  }
} else {
  // Client-Side Rendering
}

I posted this as an answer on StackOverflow. Hopefully someone finds it useful.

import React from "react";

export function isRSC() {
  if (typeof window !== "undefined") throw new Error("window must be checked before isRSC() call!");
  return !("useEffect" in React);
}

"Hybrid" Shared Components

Once again, I couldn't find much information on components that are rendered differently depending on the environment, probably because no one knew how to tell apart RSC from SSR. Any previously proposed conditional rendering (using typeof window === "undefined") would result in a mismatch between server-side and client-side renders.

But with the solution above, it is now possible to create components that can act as either React Server Components or Client Components, and have different behaviour depending on where they're imported from! I prefer to call them "Hybrid" Shared Components.

Let's say, you've got a <CodeBlock /> component, that renders code with syntax highlighting. When run from a React Server Component, you want the code to be highlighted immediately and put into the RSC payload, to avoid re-highlighting it every time. But when run from a Client Component, you want to run some client-side logic to import the required syntax modules first, and only then highlight it.

(pseudo-code; see actual code in the repository)
import { rsc } from "rsc-env";

export default function CodeBlock(props: CodeBlockProps) {

  if (rsc /* OR: typeof window === "undefined" && !("useEffect" in React) */) {
    // React Server Component only

    // You can even return promises!
    return importHighlightLanguage(props.lang).then(() => (
      <CodeBlockContainer {...props}>
        {props.lang ? <HighlightedRenderer /> : <PlainRenderer />}
      </CodeBlockContainer>
    ));

  } else {
    // Server-Side Rendering or Client-Side Rendering

    // eslint-disable-next-line react-hooks/rules-of-hooks
    const loadedLang = useHighlightLanguage(props.lang);

    return (
      <CodeBlockContainer {...props}>
        {loadedLang ? <HighlightedRenderer /> : <PlainRenderer />}
      </CodeBlockContainer>
    );
  }
}

Technically, you could highlight the code immediately during Server-Side Rendering as well, but that would cause a hydration error (SSR/CSR mismatch).

After the compilation/bundling process, the RSC bundle would have rsc replaced with true, meaning the other branch (SSR and CSR) is removed from the bundle. As for the client and SSR, rsc would be replaced with false, meaning the RSC-only code is removed from the client's bundle completely.

(RSC bundle)
export default function CodeBlock(props: CodeBlockProps) {
  return importHighlightLanguage(props.lang).then(() => (
    <CodeBlockContainer {...props}>
      {props.lang ? <HighlightedRenderer /> : <PlainRenderer />}
    </CodeBlockContainer>
  ));
}
(client bundle)
export default function CodeBlock(props: CodeBlockProps) {
  const loadedLang = useHighlightLanguage(props.lang);

  return (
    <CodeBlockContainer {...props}>
      {loadedLang ? <HighlightedRenderer /> : <PlainRenderer />}
    </CodeBlockContainer>
  );
}

Conclusion

With "Hybrid" Shared Components there's no need to create both /CodeBlock/server and /CodeBlock/client exports. You can import the component from one single path, and have it contain different behaviour, depending on whether they're imported from a Server Component (RSC) or a Client Component ("use client").

So, yeah, it's pretty useful. I imagine that would save a lot of trouble for larger projects, and allow the developers to make their components work better with RSC!