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

2ece0a4eca647c336d970fa9436bb971.jpeg

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

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

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

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

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

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

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

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

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

import React from 'react';

import { Tabs } from 'tabs';

function MyTabs() {
    return (
         console.log('Tab is changed')}>
            Pie
            Cake
            Candies
            Cookies
        
    );
}

export default MyTabs;

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

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

import React from 'react';

import { Tabs } from 'TabsWithoutCC';

function MyTabs() {
    return (
         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 (
        
Логин
Пароль
); } export default AuthForm;

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

import React from 'react';

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

function AuthForm({ isAccountAuth, theme }) {
    return (
        
isAccountAuth ? (
Номер карты или счета
) : (
Логин
Пароль
)
); } export default AuthForm;

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

import React from 'react';

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

function AuthForm({ isAccountAuth, isWebview, theme }) {
    return (
        
{ isAccountAuth && !isWebview && (
Номер карты или счета
) } { isAccountAuth && isWebview && } { !isAccountAuth && (
Логин
Пароль
)}
); } 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 (
        
            
{ props.children }
); } function useAuthForm() { const context = React.useContext(AuthFormContext); if (!context) { throw new Error('This component must be used within a component.'); } return context; } AuthForm.Input = function FormInput(props) { const { theme } = useAuthForm(); return }; AuthForm.CardInput = function FormCardInput(props) { const { theme } = useAuthForm(); return }; AuthForm.Field = function Field({ children, title }) { const { theme } = useAuthForm(); return (
{ title } { children }
) }; AuthForm.SubmitButton = function SubmitButton(props) { const { theme } = useAuthForm(); return

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

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

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

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

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

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

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

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

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

import React from 'react';

import AuthForm from "./compound-form";

export default function LoginAuth() {
    return (
        
            
                
            
            
                
            
            
        
    )
}

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

import React from 'react';

import AuthForm from "./compound-form";

export default function AccountAuth() {
    return (
        
            
                
            
            
        
    )
}

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

import React from 'react';

import AuthForm from "./compound-form";

export default function AccountAuth() {
    return (
        
            
            
        
    )
}

В этом примере хорошо видно, что 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 (
    
      {children}
    
  );
}

function ChevronComponent({ isExpanded }) {
  return isExpanded ?  : ;
}

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

  return (
    
       setToggle(value)}
        selected={value === activeItem}
        type="button"
        value={value}
      >
        {children}
        
      
      
    
  );
}

export { Accordion };

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

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

export default function App() {
  return (
    
Cider Beer Wine Milk Café Patron
); }

Подытожим

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

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

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

© Habrahabr.ru