Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи?

Написать
Войти
Дайджесты
Лестница композиции в React: как структурировать компоненты без пропс-дриллинга

Лестница композиции в React: как структурировать компоненты без пропс-дриллинга

Разбор 4 ступеней архитектурной лестницы композиции React-компонентов от инженеров компании Orus. Практический подход помогает поэтапно решать проблемы раздувания пропсов и сквозного связывания слоев: от простых пропсов и составных слотов до контрактов Composer/Provider и поднятия состояния.

Лестница композиции в React: как структурировать компоненты без пропс-дриллинга

По мере ускоренного роста фронтенд-приложений архитектура компонентов неизбежно сталкивается с накоплением сложности. Пропсы пробрасываются сквозь 5–6 промежуточных слоев ради одной дочерней кнопки (пропс-дриллинг), монолитные компоненты одновременно запрашивают данные, управляют состоянием и верстают макет, а простые строки интерфейса превращаются в громоздкие конструкции из десятков специализированных флагов.

Опыт инженерной команды Orus показывает, что решение заключается не в попытке сразу переписать все компоненты на глобальные контексты, а в последовательном движении по «лестнице композиции» (composition pattern ladder). Главный принцип подхода — начинать с самого простого и дешевого инструмента и подниматься на следующую ступень только тогда, когда конкретная проблема масштабирования сделает текущий подход неудобным.

4 ступеней архитектурной лестницы

Лестница композиции включает 4 последовательные ступени:

  1. Простые пропсы (Plain props): переиспользуемый элемент интерфейса является чистой функцией от пропсов.
  2. Составные компоненты (Compound components): разделение верстки на явные слоты, когда пропсы перестают помещаться в одну функцию.
  3. Компоновщик и провайдеры (Composer и Providers): разделение логики и данных, когда один UI должен рендерить информацию из разных источников.
  4. Поднятие состояния за контракт (Lifted state behind a contract): вынос состояния за границы верстки, когда управление должно быть доступно из любой точки макета.

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

Ступень 1. Простые пропсы (Plain props)

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

Архитектурные абстракции должны оправдывать свое существование упрощением текущих требований, а не гаданием о гипотетических фичах будущего. Если компонент успешно решает задачу через 2–3 пропса, поднимать его выше по лестнице категорически запрещено.

Ступень 2. Составные компоненты (Compound components)

Строка списка начинается с простого вида. Затем каждому экрану требуется небольшое отличие, и компонент начинает обрастать слотами, выраженными через пропсы: leading, trailing, trailingSecondary, as, href, collapsed.

Вместо плоского списка параметров первичный элемент Item экспортирует набор составных суб-компонентов:

const Item = {
  Root: ItemRoot,
  Group: ItemGroup,
  Media: ItemMedia,
  Content: ItemContent,
  Title: ItemTitle,
  Description: ItemDescription,
  Actions: ItemActions,
  Separator: ItemSeparator,
};

Вызывающий код собирает ровно ту конфигурацию, которая ему необходима:

<Item.Root render={<Link to={`/subscribe/${product.id}`} />} collapsed={sidebarCollapsed}>
  <Item.Media variant="image">
    <ProductLogo product={product} />
  </Item.Media>
  <Item.Content>
    <Item.Title>{product.name}</Item.Title>
    <Item.Description>{product.formattedPrice}</Item.Description>
  </Item.Content>
  <Item.Actions>
    <Badge intent="warning">Ожидает подписи</Badge>
    <ChevronRight />
  </Item.Actions>
</Item.Root>

Полиморфизм достигается передачей render-пропа в Item.Root, благодаря чему одна и та же строка может быть обычным div или ссылкой роутера Link. Связь между родителем и дочерними элементами (например, установка семантического атрибута role="listitem", если Root находится внутри Group) реализуется через крошечный внутренний React Context (ItemGroupContext), который не покидает пределов файла и является деталью реализации.

Специализированные списки (например, NavigationList) легко собираются поверх этих первичных слотов без дублирования кода.

Ступень 3. Компоновщик и провайдеры (Composer и Providers)

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

Решение — разделение компонента по контракту QuotesContext, несущему данные (state) и параметры отображения (meta):

type QuotesContextValue = {
  state: { quotes: readonly CustomerSubscriptionSummary[] };
  meta: { emptyLabel: string; showStatusBadge: boolean };
};
export const QuotesContext = createContext<QuotesContextValue | null>(null);

Единый компоновщик QuotesListComposer читает контракт и ничего не знает об источниках данных. Источники выносятся в заменяемые провайдеры:

  • ActiveQuotesProvider: делает реальный сетевой запрос за активными данными.
  • ArchivedQuotesProvider: загружает архивные данные и легко заворачивается в Suspense для отложенной загрузки.
  • FixtureQuotesProvider: принимает статический массив пропсов, делая компоновщик мгновенно тестируемым в изоляции без моков сетевого слоя.
// Тестирование компоновщика становится тривиальным
renderInRouter(
  <FixtureQuotesProvider quotes={[]} emptyLabel="Нет архивных заявок">
    <QuotesListComposer />
  </FixtureQuotesProvider>
);

Ступень 4. Поднятие состояния за контракт (Lifted state behind a contract)

Четвертая ступень необходима, когда состояние и действия ({ state, actions }) должны пересекать границы элементов верстки.

Примером служит блок заметок: контейнер передавал функции onNoteAdded, onNoteDeleted и onNoteUpdated сквозь 4 промежуточных уровня дочерних компонентов. Каждое новое действие требовало пробрасывания нового пропа сквозь все слои.

Состояние выносится в провайдер NotesProvider, а контракт объединяет состояние и доступные методы:

type NotesContextValue = {
  state: { notes: Note[] };
  actions: {
    addNote: (content: string) => void;
    updateNote: (noteId: string, content: string) => void;
    deleteNote: (note: Note) => void;
  };
};

Промежуточные компоненты полностью освобождаются от транзитных пропсов и просто рендерят дочерние элементы. Главный выигрыш — независимость макета: кнопка добавления заметки AddNoteButtonComposer может находиться в любой части страницы (даже в сторонней шапке панели) и вызывать действие actions.addNote(), оставаясь внутри общего NotesProvider.

Явное именование и границы применения

Чтобы архитектура оставалась читаемой, роли компонентов фиксируются в их именах:

  • Обычное имя (NavigationList, NotesBlock): компоненты 1 и 2 ступеней на пропсах.
  • Суффикс Composer (QuotesListComposer, NoteBlockComposer): потребитель, читающий контракт и не зависящий от источника.
  • Суффикс Provider (ActiveQuotesProvider, FixtureQuotesProvider): взаимозаменяемый источник данных.

Подниматься по лестнице следует только при наличии конкретной инженерной боли. Слепое стремление к «унификации» или создание провайдера для единственного источника данных без перспективы альтернатив создает лишнюю косвенность и лишь усложняет проект.