Сегодня я хочу рассказать про один не очень популярный но очень классный паттерн в написании 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.
Это все что я хотел рассказать:) если у вас есть какие-то вопросы, примеры или вы считаете что я не прав, пишите, буду рад ответить, обсудить, поправить.
GCU
Т.е. вместо сложной логики в одном компоненте их сделали несколько конкретных и завернули в общий контекст от компонента предка? И их нужно явно указывать?
dunai12 Автор
Да, по сути так. А ты потом просто выбираешь, какие части этого компонента использовать в разных случаях.
Имеешь ввиду, обязательно ли писать вот так
<Accordion.Item>
? Нет, не обязательно, во первых ты можешь сделать деструктуризацию:const { Item } = Accordion
На самом деле можно даже не делать
Item
как свойствоAccordion
GCU
Вот как раз такая явно указанная связь в именовании и смущает, так как по факту Accordion и Accordion.Item связаны лишь контекстом, а его провайдить могут и другие компоненты, а не один лишь Accordion. Ну и пример на мой взгляд неудачный, т.к. Accordion.Item явно попадают в children и Accordion может с ними делать что угодно вообще без контекста.
faiwer
Не могут. Откуда им взять этот контекст? Он вшит куда-нибудь в модуль
Accordion
и снаружи недоступен.Не могут. Ваши возможности с обработкой children лимитируются тем, что
Item
-ы должны в них присутствовать "как есть". Вернуть туда компонент, который уже сам вернётItem
-ы внутри себя не получится. ВозможностиReact.Children.map
весьма ограничены.В целом, когда сторонние библиотеки, вместо слотов c использованием контекста, используют
React.Children.map
ужасно бесят. По сути они очень сильно лимитируют разработчика в том, как ему организовать код. Он обязан порешать всё в одном и том же компоненте, даже если это нарушит все мыслимые принципы clean code. Не делайте так, пожалуйста :) Используйте контексты.Довольно часто требуется ещё и плоская структура для children. Это ещё больше раздражает.
GCU
Согласен что cloneElement решение так себе, но проблемы экспортировать контекст наружу вроде нету. Кроме того непосредственных чилдов можно завернуть в рендер функцию с доп параметрами. Само решение Compound components хорошо, но упомянуть альтернативные решения, и чем они хуже/лучше предложенного было бы полезно.
faiwer
Что вы хотели этим сказать? То что можно зайти в код компоненты и сделать экспорт. А потом этим где-нибудь ещё воспользоваться? Эмм… А зачем? Как это пройдёт code review. Наверное я вас не понял.
Рендер функции и компоненты это отнюдь не одно и то же. Удачи:
По сути эти единственное нормальное решение если вам нужна гибкость на уровне
children
.