Улучшаем дизайн React приложения с помощью Compound components

Сегодня я хочу рассказать про один не очень популярный но очень классный паттерн в написании 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.

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

Читайте так же:

  • В IT-cтруктуре ФСБ конференции в Zoom назвали небезопаснымиВ IT-cтруктуре ФСБ конференции в Zoom назвали небезопасными В Национальном координационном центре по компьютерным инцидентам (НКЦКИ). Созданном по приказу руководства ФСБ, заявили. Что платформа для онлайн-видеоконференций Zoom небезопасна с точки зрения хранения данных о российских пользователях. Николай Мурашов, замдиректора Национального […]
  • Linux-смартфоны, на которые стоит обратить внимание в 2022 годуLinux-смартфоны, на которые стоит обратить внимание в 2022 году Да, настал уже 2022 год, причем праздники еще продолжаются, но многие из нас строят разного рода планы. Они могут касаться как масштабных и очень значительных целей, вроде смены работы или старта собственного бизнеса, так и менее важных, но все же имеющих значение. Ну например — выбор […]
  • Микросервис головного мозга. Пилим всё, что движетсяМикросервис головного мозга. Пилим всё, что движется Это история о том, как увеличить скорость выкатки фич, но сохранить качество продукта. О болевых точках, которые замедляют разработку, и новой «болезни» — микросервисе головного мозга, которую диагностировал Михаил Трифонов, техлид фронтов в SberСloud. Он утверждает, что она приводит к […]
  • Как использовать Telegram для развития бизнесаКак использовать Telegram для развития бизнеса СодержаниеАудитория мессенджеровWhatsApp — самый популярный мобильный мессенджер в России. Ежедневно этим приложением пользуются около 20 миллионов россиян. Telegram, несмотря на попытки запретить его в России. Является третьим по популярности приложением для обмена сообщениями в […]