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


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

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




К сожалению, не доступен сервер mySQL