Для создания интерфейсов React рекомендует использовать композицию и библиотеки по управлению состоянием (state management libraries) для построения иерархий компонентов. Однако при сложных паттернах композиции появляются проблемы:


  1. Нужно излишне структурировать дочерние элементы
  2. Или передавать их в качестве пропсов, что усложняет читабельность, семантичность и структуру кода

Для большинства разработчиков проблема может быть неочевидна, и они перекидывают ее на уровень управления состоянием. Это обсуждается и в документации React:


Если вы хотите избавиться от передачи некоторых пропсов на множество уровней вниз, обычно композиция компонентов является более простым решением, чем контекст.
Документация React. Контекст.

Если пройти по ссылке, мы увидим еще один аргумент:


В Facebook мы используем React в тысячах компонентов, и не находили случаев, когда бы рекомендовали создавать иерархии наследования компонентов.
Документация React. Композиция против наследования.

Конечно, если бы каждый, кто использует инструмент, читал документацию и признавал авторитет авторов, то этой публикации не было. Поэтому разберем проблемы существующих подходов.


Паттерн №1 – Напрямую контролируемые компоненты



Я начинал с этого решения из-за фреймворка Vue, который рекомендует такой подход. Берем структуру данных, которая приходит с бэка или дизайна в случае формы. Пробрасываем в наш компонент – например, в карточку фильма:


const MovieCard = (props) => {
 const {title, genre, description, rating, image} = props.data;
 return (
  <div>
   <img href={image} />
   <div><h2>{title}</h2></div>
   <div><p>{genre}</p></div>
   <div><p>{description}</p></div>
   <div><h1>{rating}</h1></div>
  </div>
 )
}

Стоп. Мы уже знаем о бесконечном расширении требований к компоненты. Вдруг у названия будет ссылка на рецензию фильма? А у жанра – на лучшие фильмы из него? Не добавлять же теперь:


const MovieCard = (props) => {
 const {title: {title},
  description: {description},
  rating: {rating},
  genre: {genre},
  image: {imageHref}
 } = props.data;

 return (
  <div>
   <img href={imageHref} />
   <div><h2>{name}</h2></div>
   <div><p>{genre}</p></div>
   <div><h1>{rating}</h1></div>
   <div><p>{description}</p></div>
  </div>
 )
}

Так мы обезопасим себя от проблем в будущем, но открываем двери ошибке нулевого поинтера. Изначально мы могли прокидывать структуры прямиком из бэка:


<MovieCard data={res.data} />

Теперь каждый раз нужно дублировать всю информацию:


<MovieCard data={{
 title: {res.title},
 description: {res.description},
 rating: {res.rating},
 image: {res.imageHref}
 }} />

Однако мы забыли про жанр – и компонент упал. А если не поставили ограничители ошибок, то с ним и все приложение.


На помощь приходит TypeScript. Упрощаем схему с помощью рефакторинга карточки и элементов, которые ее используют. Благо, все подсвечивается в редакторе или при сборке:


interface IMovieCardElement {
 text?: string;
}

interface IMovieCardImage {
 imageHref?: string;
}

interface IMovieCardProps {
 title: IMovieCardElement;
 description: IMovieCardElement;
 rating: IMovieCardElement;
 genre: IMovieCardElement;
 image: IMovieCardImage;
}
...
 const {title: {text: title},
  description: {text: description},
  rating: {text: rating},
  genre: {text: genre},
  image: {imageHref}
 } = props.data;

Чтобы сэкономить время, все равно прокидываем данные «as any» или «as IMovieCardProps». Что же получается? Мы уже три раза (если используем в одном месте) описали одну структуру данных. И что имеем? Компонент, который до сих пор нельзя модифицировать. Компонент, который потенциально может обвалить все приложение.


Пришло время переиспользовать этот компонент. Рейтинг больше не нужен. У нас два варианта:


Пропс withoutRating ставьте везде, где не нужен рейтинг


const MovieCard = ({withoutRating, ...props}) => {
 const {title: {title},
  description: {description},
  rating: {rating},
  genre: {genre},
  image: {imageHref}
 } = props.data;
 return (
  <div>
   <img href={imageHref} />
   <div><h2>{name}</h2></div>
   <div><p>{genre}</p></div>
   { withoutRating &&
   <div><h1>{rating}</h1></div>
   }
   <div><p>{description}</p></div>
  </div>
 )
}

Быстро, но мы нагромождаем пропсы и сооружаем четвертую структуру данных.


Делаем rating в IMovieCardProps необязательным. Не забываем сделать его пустым объектом по умолчанию


const MovieCard = ({data, ...props}) => {
 const {title: {text: title},
  description: {text: description},
  rating: {text: rating} = {},
  genre: {text: genre},
  image: {imageHref}
 } = data;
 return (
  <div>
   <img href={imageHref} />
   <div><h2>{name}</h2></div>
   <div><p>{genre}</p></div>
   {
    data.rating &&
    <div><h1>{rating}</h1></div>
   }
   <div><p>{description}</p></div>
  </div>
 )
}

Хитрее, но становится сложно читать код. Опять же, повторяемся четвертый раз. Контроль над компонентом не очевиден, так как непрозрачно управляется структурой данных. Скажем, нас попросили сделать пресловутый рейтинг ссылкой, но не везде:


  rating: {text: rating, url: ratingUrl} = {},
  ...
   {
    data.rating &&
     data.rating.url ?
     <div>><h1><a href={ratingUrl}{rating}</a></h1></div>
     :
     <div><h1>{rating}</h1></div>
   }

И тут упираемся в сложную логику, которую диктует непрозрачная структура данных.


Паттерн №2 – Компоненты с собственным состоянием и редюсерами



Одновременно странный и популярный подход. Я использовал его, когда начал работать с React и функционала JSX во Vue стало не хватать. Не раз слышал от разработчиков на митапах, что такой подход позволяет пропускать более обобщенные структуры данных:


  1. Компонент может принимать множество структур данных, верстка остается прежней
  2. При получении данных он обрабатывает их по нужному сценарию
  3. Данные сохраняются в состоянии компонента, чтобы не запускать редюсер при каждом рендере (опционально)

Естественно, к проблеме непрозрачности (1) добавляется проблема перегруженности логикой (2) и добавление состояния в финальный компонент (3).


Последнее (3) диктуется внутренней сохранностью объекта. То есть глубинно проверяем объекты через lodash.isEqual. Если случай продвинут или JSON.stringify, все только начинается. Еще можно добавить timestamp и проверять по нему, если все потеряно. Надобность в сохранении или мемоизации отпадает, так как по сложности вычисления оптимизация может оказаться сложнее редюсера.


Данные пробрасываются с названием сценария (как правило, строкой):


<MovieCard data={{
 type: 'withoutRating',
 data: res.data,
}} />

Теперь пишем компонент:


const MovieCard = ({data}) => {
 const card = reduceData(data.type, data.data);
 return (
  <div>
   <img href={card.imageHref} />
   <div><h2>{card.name}</h2></div>
   <div><p>{card.genre}</p></div>
   { card.withoutRating &&
   <div><h1>{card.rating}</h1></div>
   }
   <div><p>{card.description}</p></div>
  </div>
 )
}

И логику:


const reduceData = (type, data) = {
 switch (type) {
  case 'withoutRating':
   return {
    title: {data.title},
    description: {data.description},
    rating: {data.rating},
    genre: {data.genre},
    image: {data.imageHref}
    withoutRating: true,
   };
  ...
 }
};

На этом шаге возникает несколько проблем:


  1. Добавляя слой логики, мы окончательно теряем прямую связь между данными и отображением
  2. Дублирование логики для каждого кейса значит, что в случае, когда всем карточкам понадобится возрастной рейтинг, его нужно будет прописать в каждом редюсере
  3. Остаются прочие проблемы из шага №1

Паттерн №3 – Перенос логики отображения и данные в управление состоянием



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


Используйте подобные инструменты там, где React не предоставляет достаточного инструментария – например, в маршрутизации. Скорее всего, вы используете react-router. В этом случае для пробрасывания сессии во все страницы больший смысл имело бы использование контекста, а не пробрасывания коллбэка из компонента каждого маршрута верхнего уровня. В React нет отдельной абстракции для асинхронных действий кроме той, что предлагает язык Javascript.


Казалось бы, есть плюс: можем переиспользовать логику в будущих версиях приложения. Но это обман. С одной стороны, она привязана к API, с другой – к структуре приложения, а логика обеспечивает эту связь. При изменении одной из частей, ее требуется переписывать.


Решение: Паттерн №4 – композиция



Метод композиции очевиден, если следовать следующим принципам (не считая аналогичного подхода в книге Design Patterns):


  1. Фронтенд-разработка – разработка пользовательских интерфейсов – использует для верстки язык HTML
  2. Для получения, передачи и обработки данных используется язык Javascript

Поэтому переводите данные из одного домена в другой как можно раньше. В React для шаблонизации HTML используется абстракция JSX, а на деле – набор методов createElement. То есть, к JSX и компонентам React, которые тоже являются элементами JSX, стоит относиться как к методу отображения и поведения, а не трансформации и обработки данных, которые должны происходить на отдельном уровне.


На этом шаге многие и используют способы, перечисленные выше, но они не решают ключевую проблему расширения и модификации отображения компонентов. Как это делать по мнению создателей библиотеки, показано в документации:


function SplitPane(props) {
  return (
    <div className="SplitPane">
      <div className="SplitPane-left">
        {props.left}
      </div>
      <div className="SplitPane-right">
        {props.right}
      </div>
    </div>
  );
}

function App() {
  return (
    <SplitPane
      left={
        <Contacts />
      }
      right={
        <Chat />
      } />
  );
}

То есть, в качестве параметров вместо строк, чисел и булевых типов передаются уже готовые, сверстанные компоненты.


Увы, этот способ тоже оказался негибким. Вот почему:


  1. Оба пропса – обязательные. Это ограничивает переиспользование компонента
  2. Необязательность означала бы перегружение компонента SplitPane логикой
  3. Вложенности и множественности отображаются не очень семантично.
  4. Эту логику отображения пришлось бы писать заново для каждого компонента, принимающего пропсы.

В итоге, подобное решение может разрастись в сложности даже для достаточно простых сценариев:


function SplitPane(props) {
  return (
    <div className="SplitPane">
      {
      props.left &&
      <div className="SplitPane-left">
        {props.left}
      </div>
     }
     {
      props.right &&
       <div className="SplitPane-right">
        {props.right}
       </div>
     }
    </div>
  );
}

function App() {
  return (
    <SplitPane
      left={
       contacts.map(el =>
        <Contacts
           name={
             <ContactsName name={el.name} />
           }
           phone={
             <ContactsPhone name={el.phone} />
           }
         />
        )
      }
      right={
        <Chat />
      } />
  );
}

В документации подобный код, в случае компонентов высшего порядка (HOC) и рендер-пропсов называют «адом оберток» (wrapper hell). С каждым добавлением нового элемента читаемость кода становится все сложнее.


Один ответ на эту проблему — слоты — присутствует в технологии Веб-компонентов и в фреймворке Vue. В обеих местах, однако, присутствуют ограничения: во-первых, слоты определены не символом, а строкой, что усложняет рефакторинг. Во-вторых, слоты ограничены по функционалу и не могут сами управлять своим отображением, передавать дочерним компонентам другие слоты или быть переиспользоваными в других элементах.


Если коротко, примерно вот такое, назовем его паттерн №5 – слоты:


function App() {
  return (
    <SplitPane>
      <LeftPane>
        <Contacts />
      </LeftPane>
      <RightPane>
        <Chat />
      </RightPane>
    </SplitPane>
  );
}

Об этом расскажу в следующей статье о существующих решениях для слотового паттерна в React и о моем собственном решении.

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


  1. boolive
    09.11.2019 23:40

    Есть идеальный паттерн?


    1. gibson_dev
      10.11.2019 10:17

      нет


    1. JustDont
      10.11.2019 11:49
      +1

      Если сказать очень кратко, то то, что описано в статье — это неизбежные грабли привязки данных (биндинга) в декларативные описания. Настройка привязки данных по сути должна быть описана в декларации, а html ничего не знает и не может знать про какую-то там «привязку данных» — он совсем о других вещах. Отсюда у нас или тысячи оберток, которые по сути представляют собой описание привязки данных в JS согласно логике декларативного описания, или же большая свалка данных а-ля реактовский контекст, в котором каждый из объектов будет как-то самостоятельно рыться, надеясь найти данные для себя.

      Для причёсывания этих проблем нам нужны или расширения языка шаблонов, более крутые, чем имеющееся поколение в виде JSX, tagged template literals и т.д, или вообще принципиально другой шаблонизатор, в котором вопросы привязки данных будут решаться сразу (типа $mol, только написанный для людей).


  1. nt4f04uNd
    10.11.2019 13:53

    Если честно, я так и не понял проблем, о которых ты говоришь. Вот компонентвот ещё) с моего недавнего проекта, я никаких проблем со спусканием данных даже близко не ощущал, и все, что идёт до 4-го пункта — это, если честно, для меня какой-то не понятно, чем вызванный, code smell.


    Что же касается композиции — в примере из документации реакта не вижу никаких проблем, и никакой там "перегруженности компонента логикой не происходит" с двумя то прочеками, но по поводу общей некрасивости такого вложения согласен, такое можно использовать, только когда не требуется частого использования подобных пропсов. Поэтому сам использую что-то вроде этого.


    Я так понимаю, об этом ты и собираешься рассказать в статье про слоты. Но ты сказал, что "во-первых, слоты определены не символом, а строкой, что усложняет рефакторинг", поэтому я сомневаюсь, что то, что меня, похоже на то, о чем говоришь ты. Скорее всего, сильно отличается реализация, но тем не менее внешнее сходство есть, поэтому скажу про слоты, которые реализованы у меня:


    1. "слоты ограничены по функционалу и не могут сами управлять своим отображением" — могут, так как это точно такие же реакт компоненты, в которые можно спустить пропс
    2. "передавать дочерним компонентам другие слоты" — опять же могут по той же причине, например, можно написать для компонента Card реализацию CardDefault с пропсом title в качестве строки, которое будет вставляться в слот Card.Header, а children использовать для слота тела Card.Body по умолчанию
    3. "быть переиспользоваными в других элементах" — то же самое, это всего лишь компонент

    Плюс ко всему можно, как в моем Card компоненте, использовать рут компонент как неймспейс для всех семантически с ним связанных слотов, что делает их использование очень удобным


    1. kirillvy Автор
      10.11.2019 14:10

      Спасибо за детальный комментарий.

      По первому пункту — я говорю именно о реализации во Vue и WebComponents, хотя я возможно недостаточно ясно это написал.
      По второму и третьему — тут у нас небольшое расхождение понятий. Слоты — не неймспейсы, а определения заранее того, куда именно будет прокинут компонент. У тебя в проекте в чилдрены можно пробросить что угодно, а слоты именно о том, что Start всегда будет в начале, а End — всегда в конце, и их нельзя поменять местами, но можно, например, подставлять что-нибудь по умолчанию, если End — нет (на базовом уровне).

      По вопросу «спускания» данных, в твоем проекте, насколько я понимаю, у тебя все данные и действия с ними идут через внешний менеджер состояния, redux (что я описал в №3), поэтому мне сложно это сопоставить с подходом №4.


      1. nt4f04uNd
        11.11.2019 02:51

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


        Во-вторых, я так понял, что ты пришел в React из Vue и других фреймворков, я, конечно, не знаю, как там это работает, но в реакте доступны 3 основных способа управления данными, это: mobx, context и redux. Они предоставляют исчерпывающие решения по управлению состоянием приложения и все остальное лично для меня выглядит странно, зачем что-то спускать вручную (речь про данные), поэтому я просто пропустил первые 3 пункта, особо не вникая.


        3 пункт я пропустил, т.к. ты написал "Перенос логики отображения и данные в управление состоянием". Перенос логики, мягко сказать, сильно не поощряется в реакте и это не про redux вообще-то, данные — да, а логика все ещё в компоненте, примером тому — приведенные мной с моего проекта


        1. kirillvy Автор
          11.11.2019 06:56

          Это и было первым, что я процитировал в своей статье. Если пройти по ссылке, там написано, чем, по мнению создателей фреймворка стоит ограничить использование контекста:


          • "например, текущий аутентифицированный пользователь, UI-тема или выбранный язык"

          То есть как достаточно ограниченный инструментарий для задач именно по контексту, а не "исчерпывающие решения по управлению состоянием приложения"


          Из этого следует, что:


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

          То есть вопрос именно о логике и данных, а не "про организацию компонентов".


          Естественно, тут обсуждается написание react.js-приложений с этими аксиомами, но есть и другие подходы.


          Также, ты не совсем прав, в твоём приложении логика вынесена вместе с данными, например, resetTasksList и unshareTask. И было бы странно оставлять это в компоненте, а не энкапсулировать это вместе с данными, если использовать методологию ООП.


      1. nt4f04uNd
        11.11.2019 02:52

        Про слоты я понял, интересно посмотреть, как ты это реализовал


        1. faiwer
          11.11.2019 11:08

          • render props — частный случай слотов
          • React.Children.map


    1. JustDont
      10.11.2019 14:11

      Если честно, я так и не понял проблем, о которых ты говоришь. Вот компонент (а вот ещё) с моего недавнего проекта, я никаких проблем со спусканием данных даже близко не ощущал, и все, что идёт до 4-го пункта — это, если честно, для меня какой-то не понятно, чем вызванный, code smell.

      Если вы приводите в пример код компонента, в котором нужно сначала сильно так промотать вниз, чтоб найти собственно сам компонент, потому что перед ним такой толстый слой обёрточных типов и кода; а потом еще и в шаблоне без поллитры не разобраться, потому что он представляет из себя сплошной тернарный оператор — то чёт мне кажется, что вы пропустили очень-очень много иронии в вашем комментарии, написав «не понял проблем».

      Вы серьезно не видите проблем в том, что о назначении вашего компонента можно догадаться либо по его имени, либо после доброго часа вникания в его код?


      1. nt4f04uNd
        11.11.2019 03:01

        Я серьезно не вижу проблем с подобным кодом, так как его сплит в другие компоненты был бы связан с ненужной болью в очке. Мой компонент решает поставленные задачи минимально доступным образом, мне интересно было бы посмотреть, как ты подобное реализуешь. Если ты не можешь с ходу разобраться, как он работает, это не означает, что он плохой


        1. vintage
          11.11.2019 12:34

          В вашем компоненте больше половины — типовой код, который следовало бы вынести в отдельные абстракции ( в том числе и в отдельные компоненты), а не копипастить его из компонента в компонент.


  1. vintage
    10.11.2019 18:01

    function SplitPane(props) {
      return (
        <div className="SplitPane">
          {
          props.left &&
          <div className="SplitPane-left">
            {props.left}
          </div>
         }
         {
          props.right &&
           <div className="SplitPane-right">
            {props.right}
           </div>
         }
        </div>
      );
    }

    Для сравнения то же самое на view.tree:


    $my_split_pane $mol_view
        sub /
            <= Left $my_view sub <= left /
            <= Right $my_view sub <= right /

    И примеры использования:


    OnlyContacts $my_split_pane
        left <= contacts_views /
        Right null
    
    OnlyChat $my_split_pane
        Left null
        right <= message_views /
    
    FullView $my_split_pane
        left <= contacts_views /
        right <= message_views /


  1. devlev
    11.11.2019 00:48

    Мне кажется, ваша основная проблема в том, что вам просто чисто эстетически не нравится передавать компоненты в виде props.

    А этот код вызывают у вас как минимум раздражение
    function App() {
      return (
        <SplitPane
          left={
            <Contacts />
          }
          right={
            <Chat />
          } />
      );
    }
    


    1. kirillvy Автор
      11.11.2019 07:24

      Именно, меня раздражает "ад оберток", так как это разрушает достаточно удобный и интуитивный для меня формат — JSX — и заставляет либо выносить даже небольшие кусочки JSX в отдельные JS-переменные, либо разбираться в уровнях вложения фигурных скобок.


      По слотам — это не совсем так, то что я предлагаю — и синтактический сахар над обёртками пропсов (по аналогии коллбэками, для которых есть сахар в виде промисов, а для промисов в виде async функций), и некоторое расширение возможностей которое позволяет не писать каждый раз часто используемые сценарии (как then-catch).


      То есть, leftpane и rightpane вставлялись бы так же как и с композицией, только в одном месте, и их нельзя было бы поменять местами или использовать для них другой компонент (не используя override, который может изначально быть не включаемым по решению создателя слота).


      Я изначально собирался сразу написать о слотах, но когда я выстувал по этому вопросу на митапе, основные вопросы были именно по обоснованию/проблематике, так что в статье я решил начать именно с этого момента.


      1. devlev
        11.11.2019 08:37

        wrapped-hell — «ад оберток» — это вот:
        image

        Если в SplitPane использовать left и right в виде props то будет один уровень вложенности. В вашем случае уровней будет два. И где тут решение проблемы «ад оберток»?


        1. kirillvy Автор
          11.11.2019 09:54

          Действительно, я имел в виду именно неудобный синтаксис. Однако уровень вложенности в devtools все равно будет один, так как это те же пропсы, просто выраженные внутри пропса children, а не как отдельный пропс. Опять же, если использовать возможности React API, появляется достаточно богатый выбор сценариев рендера — слот может быть и контейнером стилей и сам может быть компонентом, который можно при желании подменять в манере DI, или просто быть указателем того, куда рендерятся его children (и если их множество, то как и в случае с пропсом нужна либо обертка Fragment (что в слот можно подставлять автоматически, а в пропс нужно писать руками), либо map-функция в рендере дочернего компонента).


          1. faiwer
            11.11.2019 11:16

            Между render-props и children-slots разница больше визуальная. По сути этот 1 и тот же подход. Вы так или иначе передаёте это через props. В случае render-props напрямую, в случае children-slots через children property в виде древа. В отличии от Vue (наверное) в React есть некоторые проблемы с обработкой children на нижележащих слоях — вы вынуждены искать нужные вам компоненты чтобы обходиться с ними как со слотами. И это препятствует тому, чтобы можно было их как-нибудь обернуть своим компонентом. Не то чтобы это всегда сильно было нужно. Но когда в очередной раз натыкаешься на эту родовую травму React хочется в очередной раз отказаться от любых абстракций на уровне children.


          1. devlev
            11.11.2019 15:38

            Красота JSX это по сути синтаксический сахар. А вы начинаете городить из этого свой велосипед.

            const element = (
              <h1 className="greeting">
                Привет, мир!
              </h1>
            );
            // или
            const element = {
              type: 'h1',
              props: {
                className: 'greeting',
                children: 'Привет, мир!'
              }
            };
            
            — это ведь одно и тоже!
            Однако уровень вложенности в devtools все равно будет один, так как это те же пропсы, просто выраженные внутри пропса children, а не как отдельный пропс
            в вашем случае будет один лишний уровень вложенности для LeftPane и для RightPane — проверьте, если не верите.
            выносить даже небольшие кусочки JSX в отдельные JS-переменные
            все правильно, так и нужно делать
            Действительно, я имел в виду именно неудобный синтаксис
            я использую Visual Studio Code с prettier по нажатию save файла. Этого вполне достаточно чтобы не путаться в скобочках


  1. kirillvy Автор
    11.11.2019 09:54

    [delete]