Перетяжка, Карта дня
Перетяжка, Карта дня
Перетяжка, Карта дня

Какие есть паттерны в React и для чего они нужны: часть 2

Аватар Юсуп Изрипов
для
Логотип компании Tproger
Tproger
Отредактировано

В этой части Юсуп Изрипов рассказывает про хуки и кастомные хуки, а также про Compound Components и Серверные компоненты и Suspense.

784 открытий3К показов
Какие есть паттерны в React и для чего они нужны: часть 2

Меня зовут Юсуп Изрипов, я сеньор разработчик в VK. Работаю над продуктами, которыми ежедневно пользуются миллионы человек. В этой части поговорим о том, как и когда использовать хуки и почему серверные компоненты — настоящая революция.

Ниже — оставшиеся три паттерна, которые мы не разобрали в прошлой статье.

Хуки и кастомные хуки

Мы уже несколько раз упоминали хуки (Hooks) — пора поговорить о них как о новом паттерне, фактически пришедшем на смену многим предыдущим. Хуки появились в React 16.8 и моментально изменили стиль написания компонентов. Теперь вместо классов с методами жизненного цикла у нас функциональные компоненты, которые используют состояния (useState), эффекты (useEffect) и другие возможности прямо внутри функции. А главное — мы можем писать свои собственные, пользовательские хуки (custom hooks) для повторного использования логики.

Почему же хуки так популярны? Они дают возможность переиспользовать состояние и побочные эффекты, не меняя структуру самих компонентов. Раньше, чтобы два компонента разделяли какую-то логику, приходилось применять HOC или Render Props — то есть вводить дополнительный компонент-обёртку или коллбэк. Теперь же мы можем вынести логику в custom hook useSomething() и вызвать его в нужных нам компонентах или других кастомных хуках. Получается, что хуки позволяют писать более прямолинейный, читаемый код: вместо закулисной магии HOC мы явно вызываем необходимые нам хуки и получаем данные.

Custom hook — это просто функция, название которой по соглашению начинается с use (чтобы линтер React знал, что внутри неё могут быть хуки). Например, давайте перепишем наш HOC withCounter из предыдущего примера как кастомный хук:

			function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount(c => c + 1);
  return { count, increment };
}
// Использование хука в компоненте:
function ClickButton({ label }) {
  const { count, increment } = useCounter();
  return <button onClick={increment}>{label}: {count}</button>;
}

		

Получилось то же самое поведение (счётчик кликов), но без обёрток. Компонент ClickButton сам вызывает useCounter() и получает count и increment. Внутри useCounter может быть любая другая сложная логика, побочные эффекты, вызовы других хуков — компонент, использующий наш хук, об этом не знает и не должен знать. Зато код компонента предельно ясный: он просто берёт нужные ему данные из хука и использует их.

Повторное использование логики — главное преимущество кастомных хуков. Вы можете написать, например, хук useFetch(url) (достаточно распространённое решение), который будет обращаться к API и возвращать состояние загрузки, успеха запроса, данные и ошибки. Далее можно применять этот хук в разных компонентах, страницах, не дублируя сам код запроса.

Кроме того, хуки отлично работают друг с другом. Вы можете внутри одного хука вызвать другой (например, использовать useContext или useReducer внутри своего useAuth хука). Это помогает облегчить написание сложных функциональностей, так как мы используем небольшие хуки, выполняющие простые задачи, из которых как конструктор составляем более сложное поведение.

Плюсы: ясность и лаконичность. Мы не оборачиваем компонент, использующий хук, ни в какие лишние слои, его JSX не замусорен вспомогательными функциями или компонентами, он просто вызывает хук и получает результаты. HOC и Render Props во многом ушли в прошлое из-за того, что хуки решают те же проблемы более естественным для JavaScript способом. Кастомные хуки легко тестировать (это по сути просто функции). Хуки позволяют разделять логику внутри одного компонента на независимые части: например, компонент может использовать одновременно и свой локальный useState, и несколько разных кастомных хуков — каждая часть отвечает за себя, и этот код не переплетается, тогда как при использовании нескольких HOC или Render Props было бы труднее изолировать ответственности.

Минусы: если это можно назвать минусом. Хотя хуки упростили многое, они принесли также свои правила, о которых нужно помнить: вызывать в одном порядке, только в компонентах либо других хуках, не внутри условий. Если нарушить эти правила, React выдаст предупреждение или ошибку. Ещё потенциальный минус: повторное использование хука означает, что каждый компонент получает свою копию состояния. Это обычно то, что нам нужно, но если вдруг требуется разделять одно состояние между несколькими компонентами — хуки напрямую не помогут, придётся выносить состояние выше (или в контекст, или в стор). Впрочем, это уже другая задача.

В целом, сейчас хуки — основной инструмент в React, используемый разработчиками. Большинство новых API, фреймворков, библиотек строятся вокруг хуков. Поэтому если вы видите библиотеку, написанную на HOC либо Render Props, то, скорее всего, у неё уже есть или появится версия на хуках. Хуки сделали код React компонентов более понятным и свели на нет необходимость в классах (почти все новые фичи React рассчитаны только на функциональные компоненты).

Compound Components (Составные компоненты)

Паттерны, о которых говорилось выше, касаются того, как компоненты делятся логикой или данными. А есть подход, делающий фокус на композиции компонентов, позволяя создавать гибкие интерфейсы. Compound Components — это паттерн, при котором несколько компонентов работают вместе как единое целое, обмениваясь общим состоянием (обычно через контекст). Пользователь такого комплекта компонентов может гибко комбинировать его части в JS.

Я познакомился с данным паттерном, когда писал в качестве пет-проекта свой собственный UI Kit. Представьте <Accordion> с множеством <AccordionItem> или <Select> и <Option>. Compound Components — когда мы пишем код таким образом, что компонент <Accordion> не обязан принимать массив пунктов через пропсы и рендерить его внутри. Мы даём разработчику возможность самому в JSX расписать, какие пункты будут у аккордеона и что в них будет находиться, используя заранее предусмотренные дочерние компоненты: <Accordion.Item>, <Accordion.Header> и <Accordion.Panel>.

Возможно, у вас, как и у меня, сразу же возник вопрос в голове: «как же Accordion узнает о своих Item и организует их работу?» Внутри — как раз при помощи React Context. Compound Components обычно реализуются так: родитель (компонент-контейнер) содержит всё состояние (например, какой пункт раскрыт) и методы управления (функция toggle(index)). Он оборачивает children своим контекст провайдером и передаёт туда эти данные и функции. Дочерние компоненты (которые рендерятся где-то внутри children) просто берут нужное из контекста и таким образом получают доступ к состоянию родителя. Они знают, к какому именно контейнеру принадлежат, благодаря тому, что рендерятся внутри него и получают его контекст.

Давайте рассмотрим конкретный пример. Сделаем простой составной компонент Toggle, который будет управлять отображением/скрытием некоторого контента по клику. У нас будет <Toggle> в роли контейнера и его дочерние компоненты <Toggle.On>, <Toggle.Off> и <Toggle.Button>.

			const ToggleContext = createContext();
function Toggle({ children }) {
  const [on, setOn] = useState(false);
  const toggle = () => setOn(prev => !prev);
  return (
    <ToggleContext.Provider value={{ on, toggle }}>
      {children}
    </ToggleContext.Provider>
  );
}
function ToggleOn({ children }) {
  const { on } = useContext(ToggleContext);
  return on ? <>{children}</> : null;
}
function ToggleOff({ children }) {
  const { on } = useContext(ToggleContext);
  return on ? null : <>{children}</>;
}
function ToggleButton({ children }) {
  const { toggle } = useContext(ToggleContext);
  return <button onClick={toggle}>{children}</button>;
}
// Использование:
<Toggle>
  <ToggleOn>Теперь мы видим скрытый текст</ToggleOn>
  <ToggleOff>Текст скрыт</ToggleOff>
  <ToggleButton>Переключить</ToggleButton>
</Toggle>

		

Здесь <Toggle> управляет состоянием on (показано/скрыто) и предоставляет через контекст значение { on, toggle } всем потомкам. <ToggleOn> и <ToggleOff> читают on и в зависимости от него либо рендерят children, либо нет. <ToggleButton> получает из контекста функцию toggle и вызывает её при клике. В итоге снаружи мы получаем удобный и декларативный интерфейс: внутрь <Toggle> мы помещаем разные части UI, которые сами знают, когда им отображаться и что делать при клике — мы лишь описываем структуру, не связывая их вручную пропсами.

Обратите внимание: компоненту <Toggle> без разницы, сколько у него внутри <ToggleOn> или <ToggleOff> и какой внутри них JSX. Всё завязано только на состоянии контекста. Это и есть сила композиции: пользователю библиотеки даются «кирпичики» (несколько компонентов), из которых он может сложить нужную конструкцию как ему необходимо, а не один монолитный компонент с десятком пропсов настроек.

Плюсы этого паттерна: огромная гибкость и выразительность. Хороший compound-компонент ощущается как маленький фреймворк. Например, библиотека @reach/ui (предшественник современной radix-ui) много компонентов строила через этот паттерн: диалоги, списки, выпадающие меню . Пользователю легко понять API — просто вкладывай одни компоненты в другие. Появляется возможность тонко настроить итоговую разметку, вставить дополнительные элементы если надо, ведь внутри children мы не ограничены, можем обернуть тот же <ToggleButton> в какой-нибудь <div> с нужным классом. Проще поддерживать визуальное единообразие: все части контролируются одним контекстом, не размазывая логику по нескольким несвязанным компонентам.

Минусы: сложнее реализовать. Необходимо аккуратно продумать как компоненты будут взаимодействовать, предусмотреть, что некоторые могут отсутствовать или повторяться. Если неправильно спроектировать составной компонент, можно столкнуться с багами: например, если <ToggleButton> случайно использовать вне <Toggle> (то есть вне своего провайдера), useContext вернёт undefined и будет ошибка — надо либо избегать такого, либо делать проверки и бросать понятное сообщение об ошибке (мол, «ToggleButton ОБЯЗАТЕЛЬНО должен быть потомком Toggle»).

Ещё один момент связан с производительностью, когда контекстное значение меняется, все потребители контекста перерендерятся. Например, при каждом клике toggle выше перемонтируются и <ToggleOn>, и <ToggleOff>, и <ToggleButton>. В нашем случае это конечно пустяки, но если бы у нас был десяток сложных для ререндера children'ов, подписанных на контекст, и состояние менялось часто, нужно было бы подумать об оптимизации (разбивке контекстов или мемоизации).

Тем не менее плюсы обычно перевешивают: паттерн Compound Components позволяет создать очень понятный и гибкий пользовательский API для ваших компонентов. Это проявление философии React — композиция важнее наследования. Вместо того чтобы делать сложный компонент с кучей условий, мы делаем набор простых компонентов, которые в комбинации собираются в сложное поведение.

Compound Components — довольно «профессиональный» паттерн. В небольших приложениях вы можете не столкнуться с необходимостью его реализовывать, но если разрабатываете библиотеку компонентов, как я в своём пет-проекте, или сложный виджет, такой подход — чуть ли не необходимость. Практически все продвинутые React UI-библиотеки (Material UI, Chakra, Radix и т.д.) используют контекст и композицию под капотом для своих сложных компонентов.

Серверные компоненты и Suspense: современные возможности React

Наконец, давайте поговорим о новейших возможностях, которые принесли нам 18 и 19 версии React’а. Они направлены на улучшение работы с асинхронностью, данными и рендерингом на стороне сервера. В первую очередь, React Suspense и Server Components. Эти вещи ещё не до конца устоялись в среде разработчиков, но их стоит держать в уме.

Suspense — ожидание с комфортом

Когда интерфейсу нужно загрузить данные, всегда возникает задача — показать индикатор загрузки, пока всё не готово. Раньше приходилось вручную писать логику, часто это бывало состояние isLoading и условный рендер либо спиннера, либо контента. С появлением React Suspense командой React был предложен более декларативный способ. Suspense — это специальный компонент, который позволяет нам приостановить рендеринг своих дочерних компонентов, пока те не готовы, и в это время показать fallback UI.

Проще говоря, мы оборачиваем часть дерева компонентов в <Suspense fallback={<Loader/>}> ... </Suspense>, и если внутри этой области происходит задержка (например, идёт загрузка кода или данных), React сам автоматически покажет <Loader> вместо содержимого, а когда всё завершится — отобразит наши компоненты. Suspense берёт на себя координацию этого процесса, освобождая нас от ручного управления состоянием загрузки.

Сегодня Suspense широко используется для ленивой загрузки компонентов (React.lazy + <Suspense>). Например:

			const Comments = React.lazy(() => import('./Comments'));
function ArticlePage() {
  return (
    <div>
      {/* ... содержимое статьи ... */}
      <Suspense fallback={<div>Комментарии загружаются...</div>}>
        <Comments postId={666} />
      </Suspense>
    </div>
  );
}

		

Здесь компонент Comments будет подгружён по требованию (и в отдельном бандле). Пока бандл не загрузится, пользователь увидит текст-заглушку «Комментарии загружаются...». Как только код придет, React отрисует <Comments>. Всё это без какого-либо специального кода внутри ArticlePage для отслеживания загрузки. Suspense сам разрулит ситуацию — React.lazy под капотом бросает Promise на время загрузки, а <Suspense> ловит его и показывает фоллбэк.

Кроме ленивой загрузки кода, Suspense постепенно начинает применяться и для асинхронных данных. В React 18 появился экспериментальный API, позволяющий Suspense работать с данными, например, можно использовать специальный use для ожидания промиса прямо внутри компонента (пока официально не стабильно, но фреймворки типа Next.js 13 уже вовсю используют это). Идея та же: компонент, который загружает данные, вместо того чтобы сразу вернуть JSX, может приостановить своё выполнение до получения данных. React, обнаружив это, покажет fallback, а когда данные придут — продолжит рендер компонента. Таким образом, можно писать компонент, который выглядит синхронным, хотя внутри у него асинхронный код — за счёт Suspense'а пользователю не покажется незавершённый результат.

Признаться, Suspense для работы с данными — пока штука из области экспериментов. Если вы пишете обычное приложение на Vite или CRA без Next, то прямо сейчас использовать Suspense для загрузки данных «из коробки» не выйдет — потребуется либо сторонняя библиотека (например, React Query пока не интегрирован с Suspense по умолчанию, но планирует), либо фреймворк. Однако направление понятное: React движется к тому, чтобы сделать работу с асинхронностью более декларативной. Уже сейчас вы можете использовать Suspense для спиннеров и заглушек при загрузке кода, а в ближайшем будущем, вероятно, подобный подход станет нормой и для данных (в React 19+ должны появиться официальные инструменты для этого).

Подводя итог по Suspense: этот паттерн позволяет очень аккуратно организовать отображение состояния загрузки. Вместо большого количества условных isLoading ? <Spinner> : <Content> мы просто заявляем: «Эта часть UI может задержаться, показывай пока вот это». Это улучшает UX (пользователь видит скелетон или лоадер без моргания незагруженного контента) и упрощает код. Обязательно следим за развитием Suspense — возможно, скоро он будет использоваться намного чаще, чем сейчас.

Серверные компоненты — React выходит на сервер

Ещё одна революционная идея команды React — React Server Components (RSC), или серверные компоненты. Это попытка объединить лучшее из мира серверного рендеринга и клиентских SPA. Смысл в том, что если часть ваших React-компонентов может выполняться только на сервере, генерируя готовый HTML, который отправляется клиенту, то пусть они и исполняются на сервере, не загружая клиент. Эти компоненты никогда не попадают в бандл JS, не несут в себе интерактива — они чисто для рендеринга контента. Другая часть компонентов всё также остаётся клиентской, это давно знакомые нам React-компоненты, которые умеют обрабатывать события, имеют какое-то своё состояние и т.д. Разделение происходит явно: React различает, какой компонент предназначен для сервера, а какой — для клиента.

Как же React понимает, в какой среде выполнять код компонента? Введена директива "use client": если файл компонента начинается с этой строки, то компонент клиентский, он будет собран в JS и выполнится в браузере. Если же такой строчки нет — компонент считается серверным и по умолчанию выполнится на сервере (например, при рендеринге страницы на Node.js). Серверный компонент может содержать асинхронный код (запросы к БД, файловой системе и т.п.), ведь он запускается в среде сервера. Но он не может использовать, например, useState или useEffect, ведь у него нет постоянного состояния между запросами, да и доступ к DOM он не имеет.

React 18 (и в полной мере React 19) позволяет фреймворкам использовать эту возможность. Например, Next.js 13 с новым app/ роутером делает все компоненты по умолчанию серверными, если не указать "use client". Таким образом, большую часть страницы вы можете рендерить на сервере, отдавая сразу на клиент сразу готовую разметку, а для интерактивных элементов использовать клиентские компоненты.

Преимущества Server Components:

  • Производительность. Серверные компоненты избегают гидрации, клиенту не нужно повторно исполнять JS, чтобы восстановить состояние UI. Вы получаете выгоды SSR (быстрый первый рендер, минимум работы на клиенте) без обычных недостатков SSR (необходимость гидрации большого объёма HTML). 
  • Безопасность. Чувствительный код остается на сервере, не попадает в бандл, и данные можно получать напрямую на сервере (например, напрямую из базы) без передачи ключей API в браузере. 
  • Размер бандла существенно сокращается, ведь клиент вообще не получает код серверных компонентов, только итоговую HTML разметку и нужный JS для оставшихся клиентских компонентов.

Как это выглядит на практике? Самый понятный пример:

Представим блог. Страницу поста можно сделать целиком серверным компонентом, на сервере загрузится пост из БД и вернёт нам готовую верстку статьи. А вот кнопка лайка или форма добавления комментария — это уже интерактив, их делаем клиентскими компонентами. В результате пользователь, заходя на страницу, сразу же получит полностью готовую страницу поста (никакого лоадера, всё пререндерено). А JS-код загрузится для кнопки лайка и формы комментария, и только они будут гидрироваться и начнут работать на клиенте. Это сочетание SSR и SPA, orchestrated by React.

React строго определяет, как серверные и клиентские компоненты могут взаимодействовать. Серверный компонент может импортировать и использовать другой серверный или клиентский компонент, а вот клиентский компонент не может импортировать серверный. То есть дерево может быть: Серверный → внутри него Клиентский → внутри него ещё Клиентский и т.д. Но не наоборот. В примере выше серверный компонент страницы может рендерить внутри себя <LikeButton /> (клиентский компонент кнопки). А если бы вы попробовали внутри клиентского компонента сделать import PostDetails from './PostDetails.server.jsx' — сборка не позволит, скажет, что так нельзя. Таким образом, архитектура разделяется: «верхние» уровни страницы — серверные, «листья» интерактивности — клиентские.

Server Components — пока прерогатива фреймворков. То есть в обычном приложении вы не сможете воспользоваться этим вручную без большого труда. Но если вы работаете с Next.js, Remix или в целом с fullstack React-приложениями, то RSC уже доступны. В React 19 они обещают быть полностью стабильными (в React 18 это скорее эксперимент для энтузиастов). Библиотеки тоже начинают подстраиваться: например, React Router v7 планирует поддерживать RSC, Vite тоже экспериментирует с этим.

Что в итоге нам дают серверные компоненты? Потенциально — большой скачок в производительности и удобстве разработки fullstack приложений. Мы получаем паттерн разделения по среде: какие компоненты должны рендериться на сервере, а какие — на клиенте. Это новое измерение при проектировании React приложения. Разработчику теперь нужно будет думать не только о разделении логики и UI, или о переиспользовании кода, но и решать, где лучше его выполнить — на сервере или в браузере. Правильное использование RSC может значительно ускорить приложение без лишних усилий для разработчика (React сам решит, когда и что подгружать, синхронизирует состояние между сервером и клиентом).

С другой стороны, появляется дополнительная сложность в понимании: нужно чётко осознавать ограничения (например, нельзя в серверном компоненте использовать useEffect, или что состояние в серверном компоненте не сохраняется между запросами). Но это всё решается практикой и хорошей документацией.

Заключение

Мы рассмотрели ключевые паттерны React и даже заглянули в будущее React-архитектур.

  • Container & Presentational Components привносят порядок, отделяя логику от отображения. 
  • HOC и Render Props — старые приёмы для переиспользования кода, которые в значительной мере вытеснены более современными хуками, но по прежнему встречаются в проектах. 
  • Compound Components демонстрируют силу композиции, предоставляя API для гибкой сборки компонентов из небольших частей. 
  • А Suspense и Server Components — это уже ближайшее будущее, делающее работу с асинхронностью и рендерингом более эффективной и декларативной.

Важно понимать, что паттерны — это не нерушимые догмы. В каждом конкретном случае их нужно применять с умом. Порой проще обойтись без паттерна, чем усложнять архитектуру ради «красивого» решения. Не нужно лишний раз оверинженирить. Однако знание этих подходов обогащает ваш инструментарий. Когда вы сталкиваетесь с определённой проблемой, на подкорке всплывёт: «ага, здесь бы подошёл такой-то паттерн!» Опытный разработчик видит несколько вариантов реализации и выбирает оптимальный.

От себя добавлю: изучая паттерны, всегда пробуйте их в деле. Напишите свой HOC, переделайте компонент с Render Props на хук, реализуйте небольшой набор Compound Components — так вы прочувствуете их сильные и слабые стороны. React развивается, и появляются новые приёмы, но фундаментальные идеи (композиция, разделение обязанностей, явное управление состоянием) остаются. Владейте этими инструментами, и ваши React приложения будут благодарить вас чистотой и поддерживаемостью кода!
Следите за новыми постами
Следите за новыми постами по любимым темам
784 открытий3К показов