Compound Components. Пишем удобные компоненты на React

Frontend

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

С применением архитектурного паттерна compound components мы получаем возможность создать максимально переиспользуемый, простой в поддержке компонент, который к тому же будет прост в использовании. Но давайте обо всем по порядку.

Постановка задачи

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

Базовое решение

В базовом решении нет никакой магии. Просто накидаем немного JSX-компонентов и подставим в нужные места нужные значения. Такие компоненты в своих проектах мы пишем каждый день.

const UserCard = ({ user }) => {
  const {avatarSrc, specialization, role, date, name, postsPublished, comments, bio} = user;

  return (
    <div className='user'>
      <h3>{name}</h3>
      <div className='user-meta'>
        <div>{role}</div>
        <div>{new Date(date).toLocaleDateString('ru-Ru')}</div>
        <div>{specialization}</div>
      </div>
      <div className='user-preview'>
        <img src={avatarSrc} alt={name} className='user-image'/>
        <p>{bio}</p>
      </div>
      <div className='user-stats'>
        <div><span>Опубликовано постов:</span> {postsPublished}</div>
        <div><span>Комментариев:</span> {comments}</div>
      </div>
    </div>
  );
}

Конечно, здесь сразу напрашивается декомпозиция на маленькие блоки. Вынесем мета, превью и статистику в отдельные компоненты и пока на этом успокоимся. Получится как-то так.

const UserCard = ({ user }) => {
  const {avatarSrc, specialization, role, date, name, postsPublished, comments, bio} = user;

  return (
    <div className='user'>
      <h3>{name}</h3>
      <UserMeta role={role} specialization={specialization} date={date}/>
      <UserPreview avatarSrc={avatarSrc} bio={bio} name={name}/>
      <UserStats postsPublished={postsPublished} comments={comments} />
    </div>
  );
}

У нас получилось, что основной компонент получает пропсом объект с данными пользователя, а потом раздает маленьким сабкомпонентам те куски этой информации, которые им нужны. Да и смотрится карточка неплохо. По крайней мере именно так хотел заказчик.

Миссия выполнена, сторипойнты сгорели, а мы нет. Можно идти пить кофе. Или нет?

Базовое решение с опциями

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

Ну и мы, не ожидая беды, конечно просто добавим в компонент немного пропсов.

const UserCard = ({ user, showMeta, showPreview, showStats }) => {
  const {avatarSrc, name, specialization, date, bio, postsPublished, comments, role} = user;
	
  return (
    <div className='user'>
      <h3>{name}</h3>
      {showMeta && <UserMeta role={role} specialization={specialization} date={date}/>}
      {showPreview && <UserPreview avatarSrc={avatarSrc} bio={bio} name={name}/>}
      {showStats && <UserStats postsPublished={postsPublished} comments={comments} />}
    </div>
  );
}

Ну и решение сразу потеряло весь свой лоск. Особенно это будет заметно в тех местах, где мы будем использовать наш UserCard

// Было
<UserCard user={User} />

// Стало
<UserCard
  user={User}
  showMeta
  showPreview
  showStats
/>

Но в общем и целом, это терпимое решение. Никакого криминала в нем нет. Но потом приходит менеджер и просит внести еще кое-какие правки…

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

В компании запускается новый проект, там очень нужен ваш UserCard, но единственное чем он не устраивает нового заказчика — положением блока со статистикой поста. В новом проекте, по дизайну, он должен выводиться сразу под информацией о должности и роли пользователя.

И тут можно опять выдумать что-то с пропсами и окончательно уничтожить dev-Ex компонента. Но есть решение получше. Самое время применить архитектурный паттерн compound component или сложный, составной компонент.

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

import UserMeta from './UserMeta';
import UserPreview from './UserPreview';
import UserStats from './UserStats';

// Шаг 1. Создадим контекст, в который будем оборачивать все поддерево
// При этом по хорошему - стоит оформить это в отдельный хук,
// который будет проверять находится ли компонент внутри провайдера
// и если да - возвращать значение контекста
export const UserCardContext = createContext({});

// Шаг 2. Добавим нашей карточке слот под рендер чилдренов
// и обернем все в провайдер UserCardContext,
// скормив ему значением данные пользователя
const UserCard = ({ user, children }) => {
  const {name} = user;

  return (
    <UserCardContext.Provider value={{ user }}>
      <div className='user'>
        <h3>{name}</h3>
        {children}
      </div>
    </UserCardContext.Provider>
  );
}

// Шаг 3. Семантически свяжем наш основной компонент и его составные части
// По факту, это опционально, ради красоты использования - сработает и без этого
UserCard.Meta = UserMeta;
UserCard.Preview = UserPreview;
UserCard.Stats = UserStats;

Небольшие изменения произошли и внутри дочерних компонентов. Теперь они получают данные из контекста, а не из пропсов. В нашем примере все они устроены одинаково.

import { useContext } from "react";
import { UserCardContext } from "./UserCardCompound";

const UserStats = () => {
  // Читаем данные из контекста
  const { user } = useContext(UserCardContext);
	
  // Если данных нет, ничего не рендерим
  if(!user) return null;

  // Если данные есть, рендерим как обычно
  const { postsPublished, comments } = user;

  return (
    <div className='user-stats'>
      <div><span>Опубликовано постов:</span> {postsPublished}</div>
      <div><span>Комментариев:</span> {comments}</div>
    </div>
  )
}

export default UserStats;

А вот как будет выглядеть использование нового компонента

<UserCard user={User}>
  <UserCard.Meta/>
  <UserCard.Preview/>
  <UserCard.Stats/>
</UserCard>

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

Итого

Нужно ли применять такой подход повсеместно? Однозначно нет. Однако если компонент составной по своей логике, при этом имеет много мест применения и способов использования, подобные «усложнения» архитектуры позволяет сэкономить очень много времени и нервов, ведь по сути поводом для того, чтобы лезть в код компонента станет только добавление новых элементов или изменение бизнес-логики существующих. Желаю успехов!

Симо Мофин
Симо Мофин

Senior Frontend Developer
Главный по блогу