Сегодня я хочу рассказать про один не очень популярный но очень классный паттерн в написании React приложений - Compound components.

Что это вообще такое

Compound components это подход, в котором вы объединяете несколько компонентов одной общей сущностью и общим состоянием. Отдельно от этой сущности вы их использовать не можете, тк они являются единым целым. Это как в BEM нельзя использовать E - элемент, отдельно от B - блока.

Самый наглядный пример такого подхода, который знают все фронты - это select с его option в обычном HTML.

<select name="meals">
  <option value="pizza">Pizza</option>
  <option value="pasta">Pasta</option>
  <option value="borsch">Borsch</option>
  <option value="fries">Fries</option>
</select>

В «сложном компоненте» может быть сколько угодно разных элементов и они могут быть использованы в любом порядке, но все равно их будет объединять одно поведение и одно состояние.

Когда вам нужно задуматься об использовании Compound components

Я могу выделить 2 ситуации, где этот подход отлично работает:

Когда у вас есть несколько отдельных компонентов, но они являются частью чего-то одного и объединены одной логикой (как select в HTML).

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

import React from 'react';

import { Tabs } from 'tabs';

function MyTabs() {
    return (
        <Tabs onChange={()=> console.log('Tab is changed')}>
            <Tabs.Tab>Pie</Tabs.Tab>
            <Tabs.Tab className="custom-tab">Cake</Tabs.Tab>
            <Tabs.Tab disabled={true} >Candies</Tabs.Tab>
            <Tabs.Tab>Cookies</Tabs.Tab>
        </Tabs>
    );
}

export default MyTabs;

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

Сравните с тем, как это могло бы выглядеть без Compound components:

import React from 'react';

import { Tabs } from 'TabsWithoutCC';

function MyTabs() {
    return (
        <Tabs
            onChange={()=> console.log('Tab is changed')}
            tabs={[
                { name: "Pie" },
                { name: "Cake", className: 'custom-tab' },
                { name: "Candies", disabled: true },
                { name: "Cookies" }
            ]}
        />
    );
}

export default MyTabs;

А вот во втором варианте применения, как мне кажется, раскрывается вся мощь Compound Components.

Приведу пример из жизни: я делал форму аутентификации пользователя в банке, стандартно она должна выглядеть примерно так: есть поле ввода логина, пароля, у них должен быть тайтл, кнопка «войти», и нужно задать темную тему для всех компонентов, использовать эту форму будут на десктопах и в мобильном приложении через web-view

import React from 'react';

import { Form, Input, Button, Title } from 'our-design-system';

function AuthForm({ theme }) {
    return (
        <div>
            <Form theme={ theme }>
      					<div>
									<Title theme={ theme }>Логин</Title>
      						<Input theme={ theme } placeholder="Введите логин" type="text"/>
      					<div>
      					<div>
									<Title theme={ theme }>Пароль</Title>
	                <Input theme={ theme } placeholder="Введите пароль" type="password"/>
      					<div>
                <Button theme={ theme } type="submit">Войти</Button>
            </Form>
        </div>
    );
}

export default AuthForm;

Но помимо аутентификации по логину/паролю должна быть еще возможность залогиниться по номеру карты или по номеру счета. Что делать? Ну наверно добавить условие, в котором мы проверяем тип аутентификации:

import React from 'react';

import { Form, Input, Button, Title } from 'our-design-system';

function AuthForm({ isAccountAuth, theme }) {
    return (
        <div>
            <Form theme={ theme }>
                isAccountAuth ? (
										<div>
                      <Title theme={ theme }>Номер карты или счета</Title>
											<Input theme={ theme } placeholder="Введите номер карты или счета" type="number"/>
                    <div>
                ) : (
                    <div>
                      <Title theme={ theme }>Логин</Title>
                      <Input theme={ theme } placeholder="Введите логин" type="text"/>
                    <div>
                    <div>
                      <Title theme={ theme }>Пароль</Title>
                      <Input theme={ theme } placeholder="Введите пароль" type="password"/>
                    <div>
                )
                <Button theme={ theme } type="submit">Войти</Button>
            </Form>
        </div>
    );
}

export default AuthForm;

Потом приходит бизнес и говорит, что в мобильном приложении поле ввода карты или счета должно отображаться без тайтла и выглядеть как банковская карта (слава богу компонент поля ввода в виде карты верстать не надо, он есть в библиотеке компонентов, но еще одно условие добавить придется).

import React from 'react';

import { Form, Input, Button, CardInput, Title } from 'our-design-system';

function AuthForm({ isAccountAuth, isWebview, theme }) {
    return (
        <div>
            <Form theme={ theme }>
                { isAccountAuth && !isWebview && (
                 		<div>
                      <Title theme={ theme }>Номер карты или счета</Title>
											<Input theme={ theme } placeholder="Введите номер карты или счета" type="number"/>
                    <div>
                ) }

                { isAccountAuth && isWebview && <CardInput theme={ theme } placeholder="Введите номер карты или счета"/> }

                { !isAccountAuth && (
                    <div>
                      <Title theme={ theme }>Логин</Title>
                      <Input theme={ theme } placeholder="Введите логин" type="text"/>
                    <div>
                    <div>
                      <Title theme={ theme }>Пароль</Title>
                      <Input theme={ theme } placeholder="Введите пароль" type="password"/>
                    <div>
                )}
                <Button theme={ theme } type="submit">Войти</Button>
            </Form>
        </div>
    );
}

export default AuthForm;

Заметили что при каждом новом условии у нас появляются пропсы типа: isAccountAuth, isWebview. И это далеко не последнее, что нужно было учесть для каждого отдельного случая, я видел и побольше подобных "условных" пропсов. В общем суть я думаю вы поняли, наш компонент раздувается и обрастает кучей условий, код становится очень сложно читать и добавление чего-то нового причиняет боль и страдания (вам может показаться что мол норм читается, не так много кода, но тут я практически не передавал никаких пропсов, не использовал селекторы, не диспатчил ничего, тут нет никаких методов, которые кстати для каждого случая разные, в общем поверьте мне, полностью рабочий  продовский компонент выглядит устрашающе).

Думаю уже пришло время показать, как вообще реализовать Compound Component, давайте сделаем это на примере нашей формы:

import React from 'react';

import { Form, Input, Button, Title, CardInput } from 'our-design-system';

const AuthFormContext = React.createContext(undefined);

function AuthForm(props) {
    const { theme } = props;
    const memoizedContextValue = React.useMemo(
        () => ({
            theme,
        }),
        [theme],
    );

    return (
        <AuthFormContext.Provider value={ memoizedContextValue }>
            <Form>
                { props.children }
            </Form>
        </AuthFormContext.Provider>
    );
}

function useAuthForm() {
    const context = React.useContext(AuthFormContext);

    if (!context) {
        throw new Error('This component must be used within a <AuthForm> component.');
    }

    return context;
}

AuthForm.Input = function FormInput(props) {
    const { theme } = useAuthForm();
    return <Input theme={theme} {...props} />
};
AuthForm.CardInput = function FormCardInput(props) {
    const { theme } = useAuthForm();
    return <CardInput theme={theme} {...props} />
};
AuthForm.Field = function Field({ children, title }) {
    const { theme } = useAuthForm();
    return (
        <div>
            <Title theme={ theme }>{ title }</Title>
            { children }
        </div>
    )
};
AuthForm.SubmitButton = function SubmitButton(props) {
    const { theme } = useAuthForm();
    return <Button theme={theme} {...props} type="submit" />
};


export default AuthForm;

Я все написал в одном файле, но вам ничего не мешает вынести каждый внутренний компонент в отдельный файл.

Давайте разберемся, что тут происходит. 

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

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

Так вот, для того чтобы дети имели доступ к контексту, я написал кастомный хук useAuthForm.

Теперь тема, которую мы передаем в AuthForm пробрасывается каждому элементу нашего Compound компонента через контекст.

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

А теперь давайте попробуем воспользоваться нашим компонентом.

Так он будет выглядеть там, где нужна аутентификация по логину/паролю:

import React from 'react';

import AuthForm from "./compound-form";

export default function LoginAuth() {
    return (
        <AuthForm theme={'dark'}>
            <AuthForm.Field title="Логин">
                <AuthForm.Input type="text" placeholder="Введите логин" />
            </AuthForm.Field>
            <AuthForm.Field title="Пароль">
                <AuthForm.Input placeholder="Введите пароль" type="password" />
            </AuthForm.Field>
            <AuthForm.SubmitButton />
        </AuthForm>
    )
}

Так, там где вход по карте и счету для десктопа:

import React from 'react';

import AuthForm from "./compound-form";

export default function AccountAuth() {
    return (
        <AuthForm theme={'dark'}>
            <AuthForm.Field title="Номер карты или счета">
                <AuthForm.Input
                    type="text"
                    placeholder="Введите номер карты или счета"
                />
            </AuthForm.Field>
            <AuthForm.SubmitButton />
        </AuthForm>
    )
}

Так, там где вход по карте и счету для мобилы:

import React from 'react';

import AuthForm from "./compound-form";

export default function AccountAuth() {
    return (
        <AuthForm theme={'dark'}>
            <AuthForm.CardInput
                type="text"
                placeholder="Введите номер карты или счета"
            />
            <AuthForm.SubmitButton />
        </AuthForm>
    )
}

В этом примере хорошо видно, что Compound Components превращает React компонент в конструктор с единой логикой, но части этого компонента можно использовать в любом порядке или не использовать вообще. А при добавлении какой-то новой бизнес логики нам не нужно вносить изменения в уже написанный код, мы просто добавляем новый подкомпонент.


Давайте сюда же добавлю довольно распространенный пример для Compound Components, где с его помощью можно написать аккордеон:

import React, {
  createContext,
  useContext,
  useState,
  useCallback,
  useMemo
} from "react";
import styled from "styled-components";
import { Icon } from "semantic-ui-react";

const StyledAccordion = styled.div`
  border: solid 1px black;
  border-radius: 4px;
  margin: 10px;
`;

const StyledAccordionItem = styled.button`
  align-items: center;
  background: none;
  border: none;
  display: flex;
  font-weight: normal;
  font-size: 1em;
  justify-content: space-between;
  padding: 10px;
  text-align: left;
  width: 100%;

  &:focus {
    box-shadow: 0 0 2px 1px black;
  }
`;

const Item = styled.div`
  border-top: 1px solid black;

  &:first-child {
    border-top: 0;
    border-top-left-radius: 4px;
    border-top-right-radius: 4px;
  }

  &:last-child {
    border-bottom-left-radius: 4px;
    border-bottom-right-radius: 4px;
  }

  &:nth-child(odd) {
    background-color: ${({ striped }) => (striped ? "	#F0F0F0" : "transparent")};
  }
`;

const ExpandableSection = styled.section`
  background: #e8f4f8;
  border-top: solid 1px black;
  padding: 10px;
  padding-left: 20px;
`;

const AccordionContext = createContext();

function useAccordionContext() {
  const context = useContext(AccordionContext);
  if (!context) {
    // Error message should be more descriptive
    throw new Error("No context found for Accordion");
  }
  return context;
}

function Accordion({ children, defaultExpanded = "wine", striped = true }) {
  const [activeItem, setActiveItem] = useState(defaultExpanded);
  const setToggle = useCallback(
    (value) => {
      setActiveItem(() => {
        if (activeItem !== value) return value;
        return "";
      });
    },
    [setActiveItem, activeItem]
  );

  const value = useMemo(
    () => ({
      activeItem,
      setToggle,
      defaultExpanded,
      striped
    }),
    [setToggle, activeItem, striped, defaultExpanded]
  );

  return (
    <AccordionContext.Provider value={value}>
      <StyledAccordion>{children}</StyledAccordion>
    </AccordionContext.Provider>
  );
}

function ChevronComponent({ isExpanded }) {
  return isExpanded ? <Icon name="chevron up" /> : <Icon name="chevron down" />;
}

Accordion.Item = function AccordionItem({ value, children }) {
  const { activeItem, setToggle, striped } = useAccordionContext();

  return (
    <Item striped={striped}>
      <StyledAccordionItem
        aria-controls={`${value}-panel`}
        aria-disabled="false"
        aria-expanded={value === activeItem}
        id={`${value}-header`}
        onClick={() => setToggle(value)}
        selected={value === activeItem}
        type="button"
        value={value}
      >
        {children}
        <ChevronComponent isExpanded={activeItem === value} />
      </StyledAccordionItem>
      <ExpandableSection
        aria-hidden={activeItem !== value}
        aria-labelledby={`${value}-header`}
        expanded
        hidden={activeItem !== value}
        id={`${value}-panel`}
      >
        Showing expanded content about {value}
      </ExpandableSection>
    </Item>
  );
}

export { Accordion };

И вот как он используется:

import React from "react";
import { Accordion } from "./Accordion";
import "./styles.css";

export default function App() {
  return (
    <div className="App">
      <Accordion defaultExpanded="beer" striped>
        <Accordion.Item value="cider">Cider</Accordion.Item>
        <Accordion.Item value="beer">Beer</Accordion.Item>
        <Accordion.Item value="wine">Wine</Accordion.Item>
        <Accordion.Item value="milk">Milk</Accordion.Item>
        <Accordion.Item value="patron">Café Patron</Accordion.Item>
      </Accordion>
    </div>
  );
}

Подытожим

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

Так же, если вы видите, что у вашего компонента появляется куча пропсов типа: hasЧтоТоОдно=true, withЧтоТоДругое=true, showЧтоТоТретье=true, а внутри компонента появляется миллион условий, что рендерить а что не рендерить, то это явный знак, что стоит использовать Compound Components.

Это все что я хотел рассказать:) если у вас есть какие-то вопросы, примеры или вы считаете что я не прав, пишите, буду рад ответить, обсудить, поправить.

Комментарии (8)


  1. GCU
    22.01.2022 00:25

    Т.е. вместо сложной логики в одном компоненте их сделали несколько конкретных и завернули в общий контекст от компонента предка? И их нужно явно указывать?


    1. dunai12 Автор
      22.01.2022 11:48

      1. Да, по сути так. А ты потом просто выбираешь, какие части этого компонента использовать в разных случаях.

      2. Имеешь ввиду, обязательно ли писать вот так <Accordion.Item> ? Нет, не обязательно, во первых ты можешь сделать деструктуризацию:

        const { Item } = Accordion

        На самом деле можно даже не делать Item как свойство Accordion


      1. GCU
        23.01.2022 00:44

        Вот как раз такая явно указанная связь в именовании и смущает, так как по факту Accordion и Accordion.Item связаны лишь контекстом, а его провайдить могут и другие компоненты, а не один лишь Accordion. Ну и пример на мой взгляд неудачный, т.к. Accordion.Item явно попадают в children и Accordion может с ними делать что угодно вообще без контекста.


        1. faiwer
          23.01.2022 14:15
          +1

          а его провайдить могут и другие компоненты

          Не могут. Откуда им взять этот контекст? Он вшит куда-нибудь в модуль Accordionи снаружи недоступен.


          т.к. Accordion.Item явно попадают в children и Accordion может с ними делать что угодно вообще без контекста

          Не могут. Ваши возможности с обработкой children лимитируются тем, что Item-ы должны в них присутствовать "как есть". Вернуть туда компонент, который уже сам вернёт Item-ы внутри себя не получится. Возможности React.Children.map весьма ограничены.


          В целом, когда сторонние библиотеки, вместо слотов c использованием контекста, используют React.Children.map ужасно бесят. По сути они очень сильно лимитируют разработчика в том, как ему организовать код. Он обязан порешать всё в одном и том же компоненте, даже если это нарушит все мыслимые принципы clean code. Не делайте так, пожалуйста :) Используйте контексты.


          Довольно часто требуется ещё и плоская структура для children. Это ещё больше раздражает.


          1. GCU
            23.01.2022 20:40

            Согласен что cloneElement решение так себе, но проблемы экспортировать контекст наружу вроде нету. Кроме того непосредственных чилдов можно завернуть в рендер функцию с доп параметрами. Само решение Compound components хорошо, но упомянуть альтернативные решения, и чем они хуже/лучше предложенного было бы полезно.


            1. faiwer
              23.01.2022 20:45

              но проблемы экспортировать контекст наружу вроде нету

              Что вы хотели этим сказать? То что можно зайти в код компоненты и сделать экспорт. А потом этим где-нибудь ещё воспользоваться? Эмм… А зачем? Как это пройдёт code review. Наверное я вас не понял.


              можно завернуть в рендер функцию с доп параметрами

              Рендер функции и компоненты это отнюдь не одно и то же. Удачи:


              • воспользоваться в ней хуками
              • хоками
              • сделать иерархию компонент
              • и т.д.

              и чем они хуже/лучше предложенного было бы полезно.

              По сути эти единственное нормальное решение если вам нужна гибкость на уровне children.


  1. Pavel1114
    22.01.2022 08:22
    +2

    У Kent C Dodds есть хорошее видео(english) на тему


  1. chuikoffru
    22.01.2022 13:05

    Спасибо, тоже использую этот паттерн. Удобно и красиво.