← React/Составные компоненты (Compound Components)#282 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksМаршрут: старт с нуля

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

Что такое Compound Components

Посмотрите на нативный HTML:

<select>
  <option value="ru">Русский</option>
  <option value="en">English</option>
  <option value="de">Deutsch</option>
</select>

<select> и <option> — это составные компоненты. Они работают вместе: <select> управляет состоянием (какой вариант выбран), а <option> знает о своём контексте без явной передачи пропсов.

Compound Components в React — это паттерн, где несколько компонентов неявно делят состояние через Context или через React.Children + cloneElement:

// Использование похоже на нативный select/option:
<Tabs defaultValue="profile">
  <Tab value="profile">Профиль</Tab>
  <Tab value="settings">Настройки</Tab>
  <Tab value="security">Безопасность</Tab>

  <TabPanel value="profile">
    <ProfileContent />
  </TabPanel>
  <TabPanel value="settings">
    <SettingsContent />
  </TabPanel>
</Tabs>

Компоненты Tab и TabPanel не получают activeTab явно — они берут его из Context.

Реализация через Context

// 1. Создаём контекст для обмена состоянием
const TabsContext = createContext(null)

// 2. Корневой компонент управляет состоянием
function Tabs({ children, defaultValue }) {
  const [activeTab, setActiveTab] = useState(defaultValue)

  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  )
}

// 3. Дочерние компоненты читают контекст
function Tab({ value, children }) {
  const { activeTab, setActiveTab } = useContext(TabsContext)
  const isActive = activeTab === value

  return (
    <button
      className={isActive ? 'tab active' : 'tab'}
      onClick={() => setActiveTab(value)}
    >
      {children}
    </button>
  )
}

function TabPanel({ value, children }) {
  const { activeTab } = useContext(TabsContext)
  if (activeTab !== value) return null
  return <div className="tab-panel">{children}</div>
}

// Добавляем как статические свойства для удобного импорта:
Tabs.Tab = Tab
Tabs.Panel = TabPanel

Использование через статические свойства

// Удобный синтаксис — всё из одного импорта:
import { Tabs } from './Tabs'

<Tabs defaultValue="home">
  <Tabs.Tab value="home">Главная</Tabs.Tab>
  <Tabs.Tab value="about">О нас</Tabs.Tab>

  <Tabs.Panel value="home"><HomePage /></Tabs.Panel>
  <Tabs.Panel value="about"><AboutPage /></Tabs.Panel>
</Tabs>

Альтернатива: React.Children + cloneElement

Более старый подход — без Context, через явное клонирование:

function Tabs({ children, defaultValue }) {
  const [activeTab, setActiveTab] = useState(defaultValue)

  // Клонируем каждый дочерний элемент и добавляем пропсы
  const enhancedChildren = React.Children.map(children, child => {
    if (child.type === Tab) {
      return React.cloneElement(child, {
        isActive: activeTab === child.props.value,
        onSelect: setActiveTab,
      })
    }
    return child
  })

  return <div>{enhancedChildren}</div>
}

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

Реальные примеры из библиотек

Radix UI (Headless UI):

<Dialog.Root>
  <Dialog.Trigger>Открыть</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Title>Заголовок</Dialog.Title>
      <Dialog.Description>Описание</Dialog.Description>
      <Dialog.Close>Закрыть</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

React Router:

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/about" element={<About />} />
</Routes>

Когда использовать Compound Components

Паттерн уместен когда:

  • Несколько компонентов должны координироваться (Tabs, Accordion, Menu)
  • Нужна гибкость структуры (пользователь сам располагает дочерние компоненты)
  • Хотите API как у HTML (интуитивное, декларативное)
  • Строите библиотеку компонентов
  • Не нужен когда компоненты просто принимают данные через пропсы — не усложняйте без необходимости.

    Примеры

    Реализация паттерна Compound Components на JavaScript: система вкладок со shared state через замыкание, статические свойства и контекст

    // Демонстрируем Compound Components через замыкания (аналог Context в React).
    // Каждый "компонент" — функция, получающая доступ к shared state через closure.
    
    // --- Создаём систему вкладок ---
    
    function createTabSystem(defaultTab) {
      // Shared state — аналог Context
      let activeTab = defaultTab
      const tabs = []       // зарегистрированные вкладки
      const panels = []     // зарегистрированные панели
      const listeners = []  // подписчики на изменение
    
      function notify() {
        listeners.forEach(fn => fn(activeTab))
      }
    
      // --- Корневой компонент ---
      function Tabs(config) {
        return {
          type: 'Tabs',
          activeTab,
          children: config.children || [],
          render() {
            const tabBar = tabs.map(tab => tab.render()).join(' | ')
            const activePanel = panels.find(p => p.value === activeTab)
            const content = activePanel ? activePanel.render() : '[Панель не найдена]'
            return '[ Вкладки: ' + tabBar + ' ]
    [ Содержимое: ' + content + ' ]'
          }
        }
      }
    
      // --- Дочерний компонент Tab ---
      // Имеет доступ к shared state через замыкание
      function Tab(config) {
        const { value, label } = config
    
        tabs.push({
          value,
          render() {
            const isActive = activeTab === value
            return isActive ? '【' + label + '】' : label
          }
        })
    
        return {
          type: 'Tab',
          value,
          label,
          activate() {
            activeTab = value
            notify()
            console.log('Переключено на вкладку:', value)
          }
        }
      }
    
      // --- Дочерний компонент TabPanel ---
      function TabPanel(config) {
        const { value, content } = config
    
        panels.push({
          value,
          render() { return content }
        })
    
        return {
          type: 'TabPanel',
          value,
          isVisible: () => activeTab === value
        }
      }
    
      // --- Составной интерфейс (статические свойства) ---
      const TabSystem = {
        Tabs,
        Tab,
        TabPanel,
        onChange: (fn) => listeners.push(fn),
        render() {
          const tabBar = tabs.map(t => t.render()).join(' | ')
          const activePanel = panels.find(p => p.value === activeTab)
          const content = activePanel ? activePanel.render() : '—'
          return '┌─ Tabs ─────────────────────┐
    ' +
                 '│ ' + tabBar + '
    ' +
                 '├────────────────────────────┤
    ' +
                 '│ ' + content + '
    ' +
                 '└────────────────────────────┘'
        }
      }
    
      return TabSystem
    }
    
    // --- Использование ---
    
    console.log('=== Compound Components: Tabs ===')
    const TabSystem = createTabSystem('profile')
    
    // "Рендерим" дочерние компоненты — они регистрируют себя в shared state
    const profileTab = TabSystem.Tab({ value: 'profile', label: 'Профиль' })
    const settingsTab = TabSystem.Tab({ value: 'settings', label: 'Настройки' })
    const securityTab = TabSystem.Tab({ value: 'security', label: 'Безопасность' })
    
    TabSystem.TabPanel({ value: 'profile',  content: 'Здесь данные профиля: имя, фото, bio' })
    TabSystem.TabPanel({ value: 'settings', content: 'Здесь настройки: язык, тема, уведомления' })
    TabSystem.TabPanel({ value: 'security', content: 'Здесь безопасность: пароль, 2FA, сессии' })
    
    TabSystem.onChange(tab => console.log('Событие onChange: активная вкладка =', tab))
    
    // Начальное состояние
    console.log('
    Начальный рендер (profile активна):')
    console.log(TabSystem.render())
    
    // Переключение вкладки
    settingsTab.activate()
    console.log('
    После переключения на settings:')
    console.log(TabSystem.render())
    
    securityTab.activate()
    console.log('
    После переключения на security:')
    console.log(TabSystem.render())
    
    // --- Accordion: ещё один пример Compound Components ---
    
    function createAccordion(allowMultiple = false) {
      const items = new Map()
      let openItems = new Set()
    
      const Accordion = {
        Item(id, header, content) {
          items.set(id, { id, header, content })
          return {
            type: 'Accordion.Item',
            id,
            toggle() {
              if (openItems.has(id)) {
                openItems.delete(id)
              } else {
                if (!allowMultiple) openItems.clear()
                openItems.add(id)
              }
            },
            isOpen: () => openItems.has(id)
          }
        },
    
        render() {
          let output = '=== Accordion ===
    '
          items.forEach(({ id, header, content }) => {
            const isOpen = openItems.has(id)
            output += isOpen
              ? '▼ ' + header + '
      ' + content + '
    '
              : '▶ ' + header + '
    '
          })
          return output
        }
      }
    
      return Accordion
    }
    
    console.log('
    === Compound Components: Accordion ===')
    const accordion = createAccordion(false)  // только один открытый
    
    const item1 = accordion.Item('q1', 'Что такое React?', 'React — библиотека для UI')
    const item2 = accordion.Item('q2', 'Что такое Virtual DOM?', 'Виртуальное дерево DOM для быстрого diff')
    const item3 = accordion.Item('q3', 'Что такое хуки?', 'Функции для работы со состоянием в функциональных компонентах')
    
    console.log(accordion.render())
    
    item1.toggle()
    console.log('После открытия q1:')
    console.log(accordion.render())
    
    item2.toggle()
    console.log('После открытия q2 (q1 должен закрыться):')
    console.log(accordion.render())

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

    Что такое Compound Components

    Посмотрите на нативный HTML:

    <select>
      <option value="ru">Русский</option>
      <option value="en">English</option>
      <option value="de">Deutsch</option>
    </select>

    <select> и <option> — это составные компоненты. Они работают вместе: <select> управляет состоянием (какой вариант выбран), а <option> знает о своём контексте без явной передачи пропсов.

    Compound Components в React — это паттерн, где несколько компонентов неявно делят состояние через Context или через React.Children + cloneElement:

    // Использование похоже на нативный select/option:
    <Tabs defaultValue="profile">
      <Tab value="profile">Профиль</Tab>
      <Tab value="settings">Настройки</Tab>
      <Tab value="security">Безопасность</Tab>
    
      <TabPanel value="profile">
        <ProfileContent />
      </TabPanel>
      <TabPanel value="settings">
        <SettingsContent />
      </TabPanel>
    </Tabs>

    Компоненты Tab и TabPanel не получают activeTab явно — они берут его из Context.

    Реализация через Context

    // 1. Создаём контекст для обмена состоянием
    const TabsContext = createContext(null)
    
    // 2. Корневой компонент управляет состоянием
    function Tabs({ children, defaultValue }) {
      const [activeTab, setActiveTab] = useState(defaultValue)
    
      return (
        <TabsContext.Provider value={{ activeTab, setActiveTab }}>
          <div className="tabs">{children}</div>
        </TabsContext.Provider>
      )
    }
    
    // 3. Дочерние компоненты читают контекст
    function Tab({ value, children }) {
      const { activeTab, setActiveTab } = useContext(TabsContext)
      const isActive = activeTab === value
    
      return (
        <button
          className={isActive ? 'tab active' : 'tab'}
          onClick={() => setActiveTab(value)}
        >
          {children}
        </button>
      )
    }
    
    function TabPanel({ value, children }) {
      const { activeTab } = useContext(TabsContext)
      if (activeTab !== value) return null
      return <div className="tab-panel">{children}</div>
    }
    
    // Добавляем как статические свойства для удобного импорта:
    Tabs.Tab = Tab
    Tabs.Panel = TabPanel

    Использование через статические свойства

    // Удобный синтаксис — всё из одного импорта:
    import { Tabs } from './Tabs'
    
    <Tabs defaultValue="home">
      <Tabs.Tab value="home">Главная</Tabs.Tab>
      <Tabs.Tab value="about">О нас</Tabs.Tab>
    
      <Tabs.Panel value="home"><HomePage /></Tabs.Panel>
      <Tabs.Panel value="about"><AboutPage /></Tabs.Panel>
    </Tabs>

    Альтернатива: React.Children + cloneElement

    Более старый подход — без Context, через явное клонирование:

    function Tabs({ children, defaultValue }) {
      const [activeTab, setActiveTab] = useState(defaultValue)
    
      // Клонируем каждый дочерний элемент и добавляем пропсы
      const enhancedChildren = React.Children.map(children, child => {
        if (child.type === Tab) {
          return React.cloneElement(child, {
            isActive: activeTab === child.props.value,
            onSelect: setActiveTab,
          })
        }
        return child
      })
    
      return <div>{enhancedChildren}</div>
    }

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

    Реальные примеры из библиотек

    Radix UI (Headless UI):

    <Dialog.Root>
      <Dialog.Trigger>Открыть</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <Dialog.Title>Заголовок</Dialog.Title>
          <Dialog.Description>Описание</Dialog.Description>
          <Dialog.Close>Закрыть</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>

    React Router:

    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
    </Routes>

    Когда использовать Compound Components

    Паттерн уместен когда:

  • Несколько компонентов должны координироваться (Tabs, Accordion, Menu)
  • Нужна гибкость структуры (пользователь сам располагает дочерние компоненты)
  • Хотите API как у HTML (интуитивное, декларативное)
  • Строите библиотеку компонентов
  • Не нужен когда компоненты просто принимают данные через пропсы — не усложняйте без необходимости.

    Примеры

    Реализация паттерна Compound Components на JavaScript: система вкладок со shared state через замыкание, статические свойства и контекст

    // Демонстрируем Compound Components через замыкания (аналог Context в React).
    // Каждый "компонент" — функция, получающая доступ к shared state через closure.
    
    // --- Создаём систему вкладок ---
    
    function createTabSystem(defaultTab) {
      // Shared state — аналог Context
      let activeTab = defaultTab
      const tabs = []       // зарегистрированные вкладки
      const panels = []     // зарегистрированные панели
      const listeners = []  // подписчики на изменение
    
      function notify() {
        listeners.forEach(fn => fn(activeTab))
      }
    
      // --- Корневой компонент ---
      function Tabs(config) {
        return {
          type: 'Tabs',
          activeTab,
          children: config.children || [],
          render() {
            const tabBar = tabs.map(tab => tab.render()).join(' | ')
            const activePanel = panels.find(p => p.value === activeTab)
            const content = activePanel ? activePanel.render() : '[Панель не найдена]'
            return '[ Вкладки: ' + tabBar + ' ]
    [ Содержимое: ' + content + ' ]'
          }
        }
      }
    
      // --- Дочерний компонент Tab ---
      // Имеет доступ к shared state через замыкание
      function Tab(config) {
        const { value, label } = config
    
        tabs.push({
          value,
          render() {
            const isActive = activeTab === value
            return isActive ? '【' + label + '】' : label
          }
        })
    
        return {
          type: 'Tab',
          value,
          label,
          activate() {
            activeTab = value
            notify()
            console.log('Переключено на вкладку:', value)
          }
        }
      }
    
      // --- Дочерний компонент TabPanel ---
      function TabPanel(config) {
        const { value, content } = config
    
        panels.push({
          value,
          render() { return content }
        })
    
        return {
          type: 'TabPanel',
          value,
          isVisible: () => activeTab === value
        }
      }
    
      // --- Составной интерфейс (статические свойства) ---
      const TabSystem = {
        Tabs,
        Tab,
        TabPanel,
        onChange: (fn) => listeners.push(fn),
        render() {
          const tabBar = tabs.map(t => t.render()).join(' | ')
          const activePanel = panels.find(p => p.value === activeTab)
          const content = activePanel ? activePanel.render() : '—'
          return '┌─ Tabs ─────────────────────┐
    ' +
                 '│ ' + tabBar + '
    ' +
                 '├────────────────────────────┤
    ' +
                 '│ ' + content + '
    ' +
                 '└────────────────────────────┘'
        }
      }
    
      return TabSystem
    }
    
    // --- Использование ---
    
    console.log('=== Compound Components: Tabs ===')
    const TabSystem = createTabSystem('profile')
    
    // "Рендерим" дочерние компоненты — они регистрируют себя в shared state
    const profileTab = TabSystem.Tab({ value: 'profile', label: 'Профиль' })
    const settingsTab = TabSystem.Tab({ value: 'settings', label: 'Настройки' })
    const securityTab = TabSystem.Tab({ value: 'security', label: 'Безопасность' })
    
    TabSystem.TabPanel({ value: 'profile',  content: 'Здесь данные профиля: имя, фото, bio' })
    TabSystem.TabPanel({ value: 'settings', content: 'Здесь настройки: язык, тема, уведомления' })
    TabSystem.TabPanel({ value: 'security', content: 'Здесь безопасность: пароль, 2FA, сессии' })
    
    TabSystem.onChange(tab => console.log('Событие onChange: активная вкладка =', tab))
    
    // Начальное состояние
    console.log('
    Начальный рендер (profile активна):')
    console.log(TabSystem.render())
    
    // Переключение вкладки
    settingsTab.activate()
    console.log('
    После переключения на settings:')
    console.log(TabSystem.render())
    
    securityTab.activate()
    console.log('
    После переключения на security:')
    console.log(TabSystem.render())
    
    // --- Accordion: ещё один пример Compound Components ---
    
    function createAccordion(allowMultiple = false) {
      const items = new Map()
      let openItems = new Set()
    
      const Accordion = {
        Item(id, header, content) {
          items.set(id, { id, header, content })
          return {
            type: 'Accordion.Item',
            id,
            toggle() {
              if (openItems.has(id)) {
                openItems.delete(id)
              } else {
                if (!allowMultiple) openItems.clear()
                openItems.add(id)
              }
            },
            isOpen: () => openItems.has(id)
          }
        },
    
        render() {
          let output = '=== Accordion ===
    '
          items.forEach(({ id, header, content }) => {
            const isOpen = openItems.has(id)
            output += isOpen
              ? '▼ ' + header + '
      ' + content + '
    '
              : '▶ ' + header + '
    '
          })
          return output
        }
      }
    
      return Accordion
    }
    
    console.log('
    === Compound Components: Accordion ===')
    const accordion = createAccordion(false)  // только один открытый
    
    const item1 = accordion.Item('q1', 'Что такое React?', 'React — библиотека для UI')
    const item2 = accordion.Item('q2', 'Что такое Virtual DOM?', 'Виртуальное дерево DOM для быстрого diff')
    const item3 = accordion.Item('q3', 'Что такое хуки?', 'Функции для работы со состоянием в функциональных компонентах')
    
    console.log(accordion.render())
    
    item1.toggle()
    console.log('После открытия q1:')
    console.log(accordion.render())
    
    item2.toggle()
    console.log('После открытия q2 (q1 должен закрыться):')
    console.log(accordion.render())

    Задание

    Реализуй систему Tabs с паттерном Compound Components. Создай Context для обмена состоянием между компонентами. Tabs управляет активной вкладкой, Tab — кнопка переключения, TabPanel — содержимое вкладки. Дочерние компоненты получают состояние через useContext.

    Подсказка

    В Tabs: useState(defaultValue), Provider value={{ activeTab, setActiveTab }}. В Tab и TabPanel: useContext(TabsContext). В TabPanel проверка: if (activeTab !== value) return null

    Загружаем среду выполнения...
    Загружаем AI-помощника...