Представим, что вы вышли в проект, где вам предстоит создавать сложные 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>
Что примечательно, в месте использования компонента мы можем менять его составные части местами так как нам заблагорассудится. При этом на бизнес-логику это никак не повлияет.
Итого
Нужно ли применять такой подход повсеместно? Однозначно нет. Однако если компонент составной по своей логике, при этом имеет много мест применения и способов использования, подобные «усложнения» архитектуры позволяет сэкономить очень много времени и нервов, ведь по сути поводом для того, чтобы лезть в код компонента станет только добавление новых элементов или изменение бизнес-логики существующих. Желаю успехов!







