Паттерны
January 14

React-паттерн «Составной компонент» (compound component)

Разработка компонента для дизайн системы сильно отличается от разработки обычного компонента внутри web-приложения.

Во-первых: компонент должен быть гибким и покрывать как можно больше вариантов возможного использования.

Во-вторых: изменение компонента с большим количеством пользователей влечёт за собой неотвратимые размышления об обратной совместимости этих изменений.

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

Чтобы доставка изменений была быстрой, безопасной и дешёвой, мы должны подумать об архитектуре компонента.

С этой задачей нам поможет паттерн «Составной Компонент».

Свойства компонента

Использование паттерна возможно только при наличии определенных свойств у компонента:

Наличие иерархии элементов

  • Должны быть главные и дочерние компоненты
  • Дочерние компоненты завися от главного компонента

Наличие общей логики

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

Реализация компонента

Для сравнения реализуем две версии компонента Аккордеон:

  1. Классический вариант через props
  2. Вариант на основе паттерна «Составной Компонент»

Далее попробуем расширить API компонентов, чтобы затем оценим сложность внесения изменений.

Обычный компонент

Реализуем обычный React-компонент с использованием props:

Реализация

interface Props {
  items: AccordionItemProps[]
}

interface ItemProps {
  title: React.ReactNode
  content: React.ReactNode
}

const Accordion = ({ items }: Props) => {
  const [active, setActive] = useState(0)
 
  const onClick = 
    (index: number) => setActive(active === index ? -1 : index)
  
  return <div>
    {items.map(({ title, content }, index) => 
      <div key={index}>
        <div onClick={() => onClick(index)}>{title}</div>
        <div>{active === index && content}</div>
      </div>
    )}
  </div>
}

Использование

const items = [
  { title: "Title 1", content: "1️⃣" },
  { title: "Title 2", content: "2️⃣" },
  { title: "Title 3", content: "3️⃣" },
]

const App = () => <Accordion items={items} />

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

Составной компонент

Теперь реализуем компонент на основе паттерна «Составной Компонент» подробно разобрав его архитектуру:

Главный компонент

Что делает компонент:

  • Управляет состоянием
  • Выступает контроллером для дочерних компонентов обеспечивая их согласованную работу
  • Использует React Context API для передачи общего состояния дочерним компонентам (во избежание props-drilling)
interface AccordionProps {
  children: React.ReactNode
}

interface AccordionContext {
  active: number
  toggle: (key: number) => void
}

const Ctx = createContext()

const Accordion = (props: AccordionProps) => {

  const { children } = props
  
  // Управляем состоянием
  
  const [active, setActive] = useState(0)
  
  const toggle = (index) => { 
    setActive(index === index ? -1 : index)
  }

  const ctx: AccordionContext = {
    active,
    toggle
  }
  
  // Передаем контекст
  
  return (
     <Ctx.Provider value={ctx}>
       {Children.map(children, (child, index) => 
          cloneElement(child, { index })
       )}
     </Ctx.Provider>
  )
}

Дочерние компоненты

Что делают компоненты:

  • Реагируют на изменение props (глупые компоненты)
  • Берут логику, состояние и методы его для изменения из главного компонента через React Context API

Обёртка элемента

Что делает компонент:

  • Помогает дочерним компонентам быть неконтролируемыми
interface AccordionItem {
  index: number
  children: React.ReactNode
}

Accordion.Item = ({ index, children }: AccordionItem) => {
  return Children.map(children, (child) => 
    cloneElement(child, { index })
  ) 
}

Заголовок

Что делает компонент:

  • Отображает заголовок и обрабатывает действие пользователя
Accordion.Header = ({ index, children }: AccordionItem) => {
  const { active, toggle } = useContext<AccordionContext>(Ctx)
  
  return <div onClick={() => toggle(index)}>
    {children}
  </div>
}

Контент

Что делает компонент:

  • Отображает контент и реагирует на изменение состояния после действий пользователя.
Accordion.Content = ({ index, children }: AccordionItem) => {
  const { active } = useContext<AccordionContext>(Ctx)

  const isOpen = active === index
  
  return isOpen && <div>{children}</div>
}

Использование

const items = [
  { title: "Title 1", content: "1️⃣" },
  { title: "Title 2", content: "2️⃣" },
  { title: "Title 3", content: "3️⃣" },
]

<Accordion>
  {items.map(({ title, content }, index) => (
    <Accordion.Item key={index}>
      <Accordion.Header>{title}</Accordion.Header>
      <Accordion.Content>{content}</Accordion.Content>
    </Item>
  ))}
</Accordion>

Внесение изменений

Давайте внесём изменения в компонент:

Добавим кнопку «Закрыть»

Обычный компонент

const Accordion = ({ items, onClose }: Props) => {
  const [active, setActive] = useState(0)
 
  const onClick = (index) => setActive(active === index ? -1 : index)
  
  return <div>
    {items.map(({ title, content }, index) => 
      <div key={index}>
        <div onClick={() => onClick(index)}>{title}</div>
        <div>{active === key && content}</div>
        <div onClick={() => toggle(-1)} onClose={onClose}>Закрыть</div>
      </div>
    )}
  </div>
}

❌ Минусы

  • Местоположение компонента жестко закреплено
  • Изменить лэйбл и навесить хендлер можно только через props

Составной компонент

Реализация:

Accordion.Close = function ({ children, ...rest }: AccordionItem) {
  const { toggle } = useContext<AccordionContext>(Ctx)

  return (
    <div onClick={() => toggle(-1)} {...rest}>
      {children}
    </div>
  )
}

Использование:

const { Item, Header, Content, Close } = Accordion

<Accordion>
  {items.map(({ title, content }, index) => (
    <Item key={index}>
      <Header>
        {title} 
        <Close onClick={() => console.log("was closed")}>×<Close/>
      </Header>
      <Content>{content}</Content>
    </Item>
  ))}
</Accordion>

✅ Плюсы

  • Компонент может быть свободно расположен внутри Accordion.Item
  • На компонент можно навесить любой хэндлер без изменения props

Добавим элемент «Разделитель»

Попробуем изменить вёрстку — добавим разделитель и поменяем местами заголовок и контент:

Обычный компонент

const Accordion = ({ items }: Props) => {
  const [active, setActive] = useState(0)
 
  const onClick = (index) => setActive(active === index ? -1 : index)
  
  return <div>
    {items.map(({ title, content }, index) => 
      <div key={index}>
        <div>{active === index && content}</div>
        <hr />
        <div onClick={() => onClick(key)}>{title}</div>
      </div>
    )}
  </div>
}

❌ Минусы

  • Изменение вёрстки возможно только изнутри компонента

Составной компонент

const { Item, Header, Content, Close } = Accordion

<Accordion>
  {items.map(({ title, content }, index) => (
    <Item key={index}>
      <Content>{content}</Content>
      <hr />
      <Header>{title}</Header>
    </Item>
  ))}
</Accordion>

✅ Плюсы

  • Положение элемента определяется пользователем компонента

Недостатки

Тришейкинг

При включенном тришейкинге, в случае если компонент не используется, сборщик не добавляет компонент в исходный бандл. Но, как только компонент становится свойством объекта — сборщик теряет эту возможность. ???

Next.JS

Использование паттерна во в среде Next.JS приводит к ошибке. Ошибка связанна с разделением компонентов внутри фреймворка на клиентские и серверные.

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

Выводы

  • Паттерн наиболее полезен в реализации компонентов для дизайн-систем, где обновление API может быть дорогим и долгим процессом. Примеры таких компонентов: RadioGroup, TagGroup, Select, Dropdown, Form, Tabs и т.п.
  • Паттерн добавляет гибкость компонентам, позволяя управлять внутренней структурой компонента без изменения props
  • Паттерн передает управление компонента от разработчика к пользователю, что является примером SOLID-концепции инверсии контроля
  • Паттерн может не подойти для приложений в которых используется Next.JS

Заключение

Если React-компонент внутри дизайн-системы обладает сложной логикой, управляет внутренним состоянием, имеет зависимые дочерние компоненты, требует гибкости и кастомизации, то он является хорошим кандидатом для реализации с использованием паттерна Compound Components