banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Explore the environment isolation and practice of React Server Component and React Client Component

Preface#

As we know, in the React Server Component environment, the rendering runtime is always on the server, and in RCC, both environments may exist. To control that a library can only be used in one environment and throw an error in the other environment, we can use the client-only or server-only library.

Next.js is the first to support React Server Component and follows the server-module-conventions rfc, which will be discussed in the context of Next.js.

This rfc points out that in the package.json file, the exports field has added the react-server export, and this field's export location will only be used by the reference chain in RSC.

For example, client-only/package.json is defined as follows.

{
  "exports": {
    ".": {
      "react-server": "./error.js",
      "default": "./index.js"
    }
 	}
}

react-server is written on the first line and is recognized first. So when client-only is referenced in an RSC component, it actually references ./error.js, which will cause an error.

Actual Use Cases#

In the new React architecture, the combination of RSC and RCC environments has become the norm. Therefore, for libraries, this feature is needed to be compatible with both environments.

Here we take next-intl as an example, which is an i18n library. The usage of this library is the same in both RSC and RCC. For example,

In RCC, we use useTranslations like this.

'use client'
import { useTranslations } from "next-intl";

export default function Page() {
  const t = useTranslations();
  return // ReactNode
}

In RSC, we use useTranslations like this.

import { useTranslations } from "next-intl";

export default async function Page({ params: { locale } }: PageParams) {
  unstable_setRequestLocale(locale);

  const t = useTranslations();
  return // ReactNode
}

You may have noticed that the methods used in these two environments are the same, and the method exports are also the same. But you may have noticed that in RSC, hooks cannot be used, but there is no problem here.

This is related to the server-module-conventions mentioned earlier. The useTranslations imported in RSC is not actually a hook, but just a regular function. However, to ensure the consistency of method calls, the name is also kept consistent.

Continuing to explore the package.json of next-intl, we find that it is defined as follows.

{
  "exports": {
    ".": {
      "types": "./dist/types/src/index.react-client.d.ts",
      "react-server": "./dist/esm/index.react-server.js",
      "default": "./dist/index.react-client.js"
    }
  }
}

And in RSC, it actually points to https://github.com/amannn/next-intl/blob/main/packages/next-intl/src/react-server/useTranslations.tsx. These are all implementations under RSC.

Use Cases in Business#

Here is an example of a recent scenario where we need to differentiate between ofetch instances in two scenarios. We know that the way to get cookies in Next.js RCC and RSC is different. In addition, the interfaces requested on pre-rendered pages are always sent out in RSC. We may need to attach some request information when the request is sent, such as authentication-related headers, user-agent, or forwarding the IP information of the actual requester, and so on.

In this case, we can write two different instances for the two environments.

First, create an internal package. For example, packages/fetch.

Create package.json.

{
  "name": "@shiro/fetch",
  "exports": {
    ".": {
      "react-server": "./src/fetch.server.ts",
      "default": "./src/fetch.client.ts"
    }
  },
  "devDependencies": {}
}

Write fetch.server.ts for RSC.

import 'server-only'

import { nanoid } from 'nanoid'
import { cookies, headers as nextHeaders } from 'next/headers'

export const $fetch = createFetch({
  defaults: {
    timeout: 8000,

    onRequest(context) {
      const cookie = cookies() // Use Next.js cookies() to get cookies
      const token = cookie.get(TokenKey)?.value

      const headers: any = context.options.headers ?? {}
      if (token) {
        headers['Authorization'] = `bearer ${token}`
      }

      context.options.params ??= {}

      if (token) {
        context.options.params.r = nanoid()
      }

      if (context.options.params.token || token) {
        context.options.cache = 'no-store' // Do not tell Next.js to cache this request after authentication, as the data may leak
      }
      if (isDev) {
        console.info(`[Request/Server]: ${context.request}`)
      }

      const { get } = nextHeaders() // Use the headers() method to get the original request headers

      const ua = get('user-agent')
      const ip =
        get('x-real-ip') ||
        get('x-forwarded-for') ||
        get('remote-addr') ||
        get('cf-connecting-ip')

      if (ip) {
        headers['x-real-ip'] = ip
        headers['x-forwarded-for'] = ip
      }

      headers['User-Agent'] =
        `${ua} NextJS/v${PKG.dependencies.next} ${PKG.name}/${PKG.version}`

      context.options.headers = headers
    },
    onResponse(context) {
      if (isDev) { // This is definitely ServerSide
        console.info(
          `[Response/Server]: ${context.request}`,
          context.response.status,
        )
      }
    },
  },
})

Write fetch.client.ts for RCC.

import 'client-only'
import Cookies from 'js-cookie'

function getToken(): string | null { // Use js-cookie to get cookies on the browser side
  const token = Cookies.get(TokenKey)

  return token || null
}


export const $fetch = createFetch({
  defaults: {
    timeout: 8000,
    onRequest(context) {
      const token = getToken()
      const headers: any = context.options.headers ?? {}
      if (token) {
        headers['Authorization'] = `bearer ${token}`
      }

      headers['x-session-uuid'] =
        globalThis?.sessionStorage?.getItem(uuidStorageKey) ?? uuid

      context.options.params ??= {}
      if (context.options.params.token) {
        context.options.cache = 'no-store'
      }
      if (isDev && isServerSide) {
        // eslint-disable-next-line no-console
        console.info(`[Request]: ${context.request}`)
      }

      context.options.headers = headers
    },
    onResponse(context) {
      if (isDev && isServerSide) { // It is still necessary to distinguish ServerSide here
        console.info(`[Response]: ${context.request}`, context.response.status)
      }
    },
  },
})

Now, both have the same methods and are exported. So we can use them in the business. To better support TypeScript, we need to modify "moduleResolution": "Bundler". Oh, don't forget to link this dependency in the package.json of the project. For example, here, "@shiro/fetch": "link:./packages/fetch" is added to the dependencies in the project's package.json.

Usage:

import { $fetch } from '@shiro/fetch' // The usage is the same in RCC and RSC

The above implementation has been implemented in Shiroi and is planned to be open-sourced to Shiro in the future.

I was recently laid off and I feel very depressed. Instead of reading textbooks and solving algorithms, I find it more meaningful to study and explore these things with a blank mind. 🥹😮‍💨, I can only say that it is cautious to choose a start-up company.

This article is synchronized and updated to xLog by Mix Space.
The original link is https://innei.in/posts/tech/exploring-environment-isolation-and-practice-of-react-server-component-and-react-client-component


Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.