Паттерны композиции серверных и клиентских компонентов

При создании React-приложений необходимо учитывать, какие части приложения должны рендериться на сервере, а какие — на клиенте. На этой странице рассматриваются рекомендуемые паттерны композиции при использовании серверных и клиентских компонентов.

Когда использовать серверные и клиентские компоненты?

Вот краткое сравнение случаев использования серверных и клиентских компонентов:

Что вам нужно сделать?Серверный компонентКлиентский компонент
Получение данныхCheck IconCross Icon
Доступ к бэкенд-ресурсам (напрямую)Check IconCross Icon
Хранение чувствительной информации на сервере (токены доступа, API-ключи и т.д.)Check IconCross Icon
Хранение больших зависимостей на сервере / Уменьшение клиентского JavaScriptCheck IconCross Icon
Добавление интерактивности и обработчиков событий (onClick(), onChange() и т.д.)Cross IconCheck Icon
Использование состояния и эффектов (useState(), useReducer(), useEffect() и т.д.)Cross IconCheck Icon
Использование API, доступных только в браузереCross IconCheck Icon
Использование кастомных хуков, зависящих от состояния, эффектов или браузерных APICross IconCheck Icon
Использование React Class компонентовCross IconCheck Icon

Паттерны серверных компонентов

Прежде чем переходить к клиентскому рендерингу, вы можете выполнить некоторые операции на сервере, такие как получение данных или доступ к базе данных и сервисам бэкенда.

Вот несколько распространённых паттернов при работе с серверными компонентами:

Обмен данными между компонентами

При получении данных на сервере могут возникнуть ситуации, когда нужно передавать данные между разными компонентами. Например, у вас может быть макет и страница, зависящие от одних и тех же данных.

Вместо использования React Context (который недоступен на сервере) или передачи данных через пропсы, вы можете использовать fetch или функцию cache от React для получения одних и тех же данных в компонентах, где они нужны, не беспокоясь о дублировании запросов. Это возможно потому, что React расширяет fetch для автоматического мемоизирования запросов данных, а функцию cache можно использовать, когда fetch недоступен.

Подробнее о мемоизации в React.

Защита серверного кода от попадания в клиентскую среду

Поскольку JavaScript-модули могут использоваться как серверными, так и клиентскими компонентами, существует вероятность, что код, предназначенный только для сервера, может случайно попасть на клиент.

Например, рассмотрим следующую функцию получения данных:

export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })

  return res.json()
}
export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })

  return res.json()
}

На первый взгляд кажется, что getData работает и на сервере, и на клиенте. Однако эта функция содержит API_KEY, который предполагается использовать только на сервере.

Поскольку переменная окружения API_KEY не имеет префикса NEXT_PUBLIC, это приватная переменная, доступная только на сервере. Чтобы предотвратить утечку приватных переменных окружения на клиент, Next.js заменяет их пустой строкой.

Таким образом, хотя getData() можно импортировать и выполнить на клиенте, она не будет работать как ожидается. И хотя сделать переменную публичной позволит функции работать на клиенте, вы, скорее всего, не захотите раскрывать чувствительную информацию.

Чтобы предотвратить непреднамеренное использование серверного кода на клиенте, можно использовать пакет server-only, который вызовет ошибку сборки, если кто-то попытается импортировать такие модули в клиентский компонент.

Для использования server-only сначала установите пакет:

Terminal
npm install server-only

Затем импортируйте его в любой модуль, содержащий серверный код:

lib/data.js
import 'server-only'

export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  })

  return res.json()
}

Теперь любой клиентский компонент, импортирующий getData(), получит ошибку сборки с пояснением, что этот модуль можно использовать только на сервере.

Соответствующий пакет client-only можно использовать для пометки модулей, содержащих код только для клиента — например, код, обращающийся к объекту window.

Использование сторонних пакетов и провайдеров

Поскольку серверные компоненты — это новая возможность React, сторонние пакеты и провайдеры в экосистеме только начинают добавлять директиву "use client" к компонентам, использующим клиентские возможности, такие как useState, useEffect и createContext.

Сегодня многие компоненты из пакетов npm, использующие клиентские возможности, ещё не имеют этой директивы. Эти сторонние компоненты будут работать как ожидается внутри клиентских компонентов, поскольку те имеют директиву "use client", но не будут работать внутри серверных компонентов.

Например, предположим, что вы установили гипотетический пакет acme-carousel, содержащий компонент <Carousel />. Этот компонент использует useState, но ещё не имеет директивы "use client".

Если вы используете <Carousel /> внутри клиентского компонента, он будет работать как ожидается:

'use client'

import { useState } from 'react'
import { Carousel } from 'acme-carousel'

export default function Gallery() {
  let [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Просмотреть изображения</button>

      {/* Работает, так как Carousel используется внутри клиентского компонента */}
      {isOpen && <Carousel />}
    </div>
  )
}
'use client'

import { useState } from 'react'
import { Carousel } from 'acme-carousel'

export default function Gallery() {
  let [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>Просмотреть изображения</button>

      {/* Работает, так как Carousel используется внутри клиентского компонента */}
      {isOpen && <Carousel />}
    </div>
  )
}

Однако при попытке использовать его напрямую внутри серверного компонента вы получите ошибку:

import { Carousel } from 'acme-carousel'

export default function Page() {
  return (
    <div>
      <p>Просмотреть изображения</p>

      {/* Ошибка: `useState` нельзя использовать в серверных компонентах */}
      <Carousel />
    </div>
  )
}
import { Carousel } from 'acme-carousel'

export default function Page() {
  return (
    <div>
      <p>Просмотреть изображения</p>

      {/* Ошибка: `useState` нельзя использовать в серверных компонентах */}
      <Carousel />
    </div>
  )
}

Это происходит потому, что Next.js не знает, что <Carousel /> использует клиентские возможности.

Чтобы решить эту проблему, можно обернуть сторонние компоненты, зависящие от клиентских возможностей, в ваши собственные клиентские компоненты:

'use client'

import { Carousel } from 'acme-carousel'

export default Carousel
'use client'

import { Carousel } from 'acme-carousel'

export default Carousel

Теперь вы можете использовать <Carousel /> напрямую внутри серверного компонента:

import Carousel from './carousel'

export default function Page() {
  return (
    <div>
      <p>Просмотреть изображения</p>

      {/* Работает, так как Carousel теперь клиентский компонент */}
      <Carousel />
    </div>
  )
}
import Carousel from './carousel'

export default function Page() {
  return (
    <div>
      <p>Просмотреть изображения</p>

      {/* Работает, так как Carousel теперь клиентский компонент */}
      <Carousel />
    </div>
  )
}

Ожидается, что в большинстве случаев вам не понадобится оборачивать сторонние компоненты, так как вы, скорее всего, будете использовать их внутри клиентских компонентов. Однако исключением являются провайдеры контекста, так как они зависят от состояния и контекста React и обычно требуются в корне приложения. Подробнее о провайдерах контекста ниже.

Использование провайдеров контекста

Провайдеры контекста обычно рендерятся в корне приложения для разделения глобальных состояний, таких как текущая тема. Поскольку React context не поддерживается в серверных компонентах, попытка создать контекст в корне приложения вызовет ошибку:

import { createContext } from 'react'

// createContext не поддерживается в серверных компонентах
export const ThemeContext = createContext({})

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  )
}
import { createContext } from 'react'

// createContext не поддерживается в серверных компонентах
export const ThemeContext = createContext({})

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  )
}

Чтобы решить эту проблему, создайте контекст и его провайдер внутри клиентского компонента:

'use client'

import { createContext } from 'react'

export const ThemeContext = createContext({})

export default function ThemeProvider({ children }) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}
'use client'

import { createContext } from 'react'

export const ThemeContext = createContext({})

export default function ThemeProvider({ children }) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
}

Теперь ваш серверный компонент сможет напрямую рендерить провайдер, так как он помечен как клиентский компонент:

import ThemeProvider from './theme-provider'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}
import ThemeProvider from './theme-provider'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  )
}

После рендеринга провайдера в корне все клиентские компоненты в вашем приложении смогут использовать этот контекст.

Полезно знать: Провайдеры следует размещать как можно глубже в дереве компонентов — обратите внимание, что ThemeProvider оборачивает только {children}, а не весь документ <html>. Это помогает Next.js оптимизировать статические части серверных компонентов.

Советы для авторов библиотек

Аналогичным образом авторы библиотек могут использовать директиву "use client" для пометки клиентских точек входа своих пакетов. Это позволяет пользователям импортировать компоненты библиотеки напрямую в серверные компоненты без необходимости создавать обёртки.

Вы можете оптимизировать свой пакет, используя 'use client' глубже в дереве, что позволит импортированным модулям быть частью графа модулей серверного компонента.

Стоит отметить, что некоторые сборщики могут удалять директивы "use client". Пример настройки esbuild для сохранения директивы можно найти в репозиториях React Wrap Balancer и Vercel Analytics.

Клиентские компоненты

Размещение клиентских компонентов глубже в дереве

Чтобы уменьшить размер клиентского JavaScript-бандла, рекомендуется размещать клиентские компоненты как можно глубже в дереве компонентов.

Например, у вас может быть макет со статическими элементами (например, логотипом, ссылками и т.д.) и интерактивной строкой поиска, использующей состояние.

Вместо того чтобы делать весь макет клиентским компонентом, вынесите интерактивную логику в клиентский компонент (например, <SearchBar />), оставив макет серверным компонентом. Это означает, что вам не придётся отправлять весь JavaScript макета на клиент.

// SearchBar — клиентский компонент
import SearchBar from './searchbar'
// Logo — серверный компонент
import Logo from './logo'

// Layout по умолчанию является серверным компонентом
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  )
}
// SearchBar — клиентский компонент
import SearchBar from './searchbar'
// Logo — серверный компонент
import Logo from './logo'

// Layout по умолчанию является серверным компонентом
export default function Layout({ children }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  )
}

Передача пропсов из серверных в клиентские компоненты (Сериализация)

Если вы получаете данные в серверном компоненте, вы можете передать их вниз по дереву как пропсы клиентским компонентам. Пропсы, передаваемые из серверных в клиентские компоненты, должны быть сериализуемыми в React.

Если ваши клиентские компоненты зависят от данных, которые нельзя сериализовать, вы можете получать данные на клиенте с помощью сторонних библиотек или на сервере через Route Handler.

Чередование серверных и клиентских компонентов

При чередовании клиентских и серверных компонентов может быть полезно визуализировать ваш интерфейс как дерево компонентов. Начиная с корневого макета, который является серверным компонентом, вы можете затем отображать определенные поддеревья компонентов на клиенте, добавив директиву "use client".

Внутри этих клиентских поддеревьев вы все еще можете вкладывать серверные компоненты или вызывать серверные действия (Server Actions), однако есть несколько моментов, которые следует учитывать:

  • В течение жизненного цикла запроса-ответа ваш код перемещается с сервера на клиент. Если вам нужно получить доступ к данным или ресурсам на сервере, находясь на клиенте, вы будете делать новый запрос к серверу — а не переключаться туда-обратно.
  • Когда делается новый запрос к серверу, сначала рендерятся все серверные компоненты, включая те, что вложены в клиентские. Результат рендеринга (RSC Payload) будет содержать ссылки на расположение клиентских компонентов. Затем на клиенте React использует RSC Payload для согласования серверных и клиентских компонентов в единое дерево.
  • Поскольку клиентские компоненты рендерятся после серверных, вы не можете импортировать серверный компонент в модуль клиентского компонента (так как это потребует нового запроса обратно на сервер). Вместо этого вы можете передать серверный компонент как props в клиентский компонент. См. разделы неподдерживаемый шаблон и поддерживаемый шаблон ниже.

Неподдерживаемый шаблон: Импорт серверных компонентов в клиентские

Следующий шаблон не поддерживается. Вы не можете импортировать серверный компонент в клиентский:

'use client'

// Нельзя импортировать серверный компонент в клиентский.
import ServerComponent from './Server-Component'

export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)

  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>

      <ServerComponent />
    </>
  )
}
'use client'

// Нельзя импортировать серверный компонент в клиентский.
import ServerComponent from './Server-Component'

export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0)

  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>

      <ServerComponent />
    </>
  )
}

Поддерживаемый шаблон: Передача серверных компонентов в клиентские через props

Следующий шаблон поддерживается. Вы можете передавать серверные компоненты как пропсы в клиентский компонент.

Распространенный шаблон — использование пропса children в React для создания "слота" в вашем клиентском компоненте.

В примере ниже <ClientComponent> принимает пропс children:

'use client'

import { useState } from 'react'

export default function ClientComponent({
  children,
}: {
  children: React.ReactNode
}) {
  const [count, setCount] = useState(0)

  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children}
    </>
  )
}
'use client'

import { useState } from 'react'

export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0)

  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>

      {children}
    </>
  )
}

<ClientComponent> не знает, что children в конечном итоге будет заполнен результатом серверного компонента. Единственная ответственность <ClientComponent> — решить, где children будет размещен.

В родительском серверном компоненте вы можете импортировать и <ClientComponent>, и <ServerComponent>, а затем передать <ServerComponent> как дочерний элемент <ClientComponent>:

// Этот шаблон работает:
// Вы можете передать серверный компонент как дочерний элемент или пропс
// клиентского компонента.
import ClientComponent from './client-component'
import ServerComponent from './server-component'

// Страницы в Next.js по умолчанию являются серверными компонентами
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}
// Этот шаблон работает:
// Вы можете передать серверный компонент как дочерний элемент или пропс
// клиентского компонента.
import ClientComponent from './client-component'
import ServerComponent from './server-component'

// Страницы в Next.js по умолчанию являются серверными компонентами
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  )
}

При таком подходе <ClientComponent> и <ServerComponent> разделены и могут рендериться независимо. В этом случае дочерний <ServerComponent> может быть отрендерен на сервере задолго до того, как <ClientComponent> будет отрендерен на клиенте.

Полезно знать:

  • Шаблон "поднятия контента вверх" использовался для предотвращения повторного рендеринга вложенного дочернего компонента при повторном рендеринге родительского.
  • Вы не ограничены пропсом children. Вы можете использовать любой пропс для передачи JSX.