Писать кнопочки и формочки на React - дело не хитрое. Но почти всегда фронтовые проекты превращаются в нечто трудночитаемое и едва ли поддерживаемое. Визуально различия проектов на React и JQuery со временем сохраняются, а вот developer experience с точки зрения трудозатрат на поддержку становится примерно одинаковым.

За лесом кнопок, эффектов и пропсов не разглядеть сценария

JavaScript на то и script, что на нём хорошо писать пользовательские сценарии. React - отличная ui-библиотека. Главное не перепутать. Поддержка пользовательских сценариев описанных через ui-библиотеку - для сильных духом.

Привожу пример. Пожалуй самый распространённый случай - это извращенная интерпретация принципа DRY. Теперь поподробнее: речь идёт про случаи, когда имеется чёткий триггер, например, клик по кнопке, ввод текста в инпуте, но сценарий обработки этого триггера находится в useEffect. Почему в useEffect, а не в onClick или onChange? Ну как же, ведь этот же сценарий запускается ещё и при изменении вот того пропса. Не писать же одно и то же дважды.

Мы перестали оперировать понятиями функций и аргументов. Вместо них мы используем эффекты и пропсы

Чем дальше в лес, тем больше дров. Со временем этот useEffect станет "местом силы". Всё больше и больше пользовательских сценариев будут использовать этот хук, массив зависимостей начнёт расти, а внутри хука появятся инструкции if с хитрыми условиями в скобках. Ну и вишенка на торте - понимание, что без хука наподобие usePrevious ничего не заработает. Не всегда usePrevious показатель неправильно выбранного тригера, но в 9 из 10 случаев вам есть о чём задуматься.

Всего два простых правила

  1. кратчайший путь для сценария

  2. изоляция сценария

Кратчайший путь как раз и начинается с выбора правильного тригера. Например, можно сравнить два варианта из сценария проверки на валидность текста. Есть input, есть стейт value и есть стейт isValid.

Сценарий 1:

  1. onChange устанавливает значение value в стейт

  2. затем useEffect реагирует на изменение value, проверяет его на валидность и устанавливает в стейт значение isValid

Сценарий 2:

  1. onChange вызывает обработчик события с аргументом value, который и установит в стейт value и isValid

Не стоит забывать, что фреймворк - это история про инверсию управления. В данном случае сценарий 2 короче сценария 1 не вдвое, а сильно больше. Ведь во втором случае, при выполнении нашего сценария мы не передавали управление react'у. В первом случае react выполнил один лишний рендер, возможно запустил ещё какие-то хуки, хотя нам это и не надо.

Разрывы в пользовательском сценарии - источник неприятностей

Так мы плавно подходим ко второму пункту - изоляции. Допустим в компонент приходит пропс, который также влияет на значение isValid, тогда для того, чтобы развести два этих сценария достаточно просто описать функцию валидации и передать её в useEffect и в onChange.

Старайтесь писать так, чтобы useEffect был триггером пользовательского сценария, а не его промежуточным звеном

Всё это поможет другим разработчикам не плутать от useEffect'а к useEffect'у по вашему коду, а зацепившись за триггер последовательно "размотать" сценарий.

Обратная сторона

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

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

О протечке нам просигнализирует появление в сторе (или в верхнеуровневом компоненте) поля, например, popout: { type, data }. Далее в коде можно обнаружить компонент с немаленькой инструкцией switch, который только и ждет, когда в поле popout положат данные. И вот он подбирает нужный компонент (согласно type), передаёт ему data и сценарий продолжается.

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

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

Косательно примера с todo-листом можно поместить компонент popout внутри компонента ячейки с запланированным действием и при нажатиии на кнопку "удалить" показывать popout через портал. Каждая ячейка имеет свой popout, а значит рассказывает максимально короткий и изолированный сценарий. Для простоты можете использовать библиотеку "react-portal" - он прикрепляет содержимое к document.body. Это не плохо подойдёт для всплывающих окон. Для сценариев, где layout сложный и прикрепление компонента к document.body не отвечает потребностям, я написал библиотеку "react-jsx-portal" (ссылка). Вторая часть readme на русском. Я думаю многим она подойдёт.

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


  1. Epsiloncool
    11.01.2022 02:13
    -8

    Очень много геморроя с данными исчезает, когда открываешь для себя react-redux.


    1. nin-jin
      11.01.2022 05:45

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


      1. sybear
        11.01.2022 11:11

        Если фильтры поменялись и прошла проверка на реальное изменение, будет вызвано событие по изменению, на это же событие вешается extraReducers в redux-toolkit


        1. nin-jin
          11.01.2022 13:23

          Кем проверка? Кем вызвано?


          1. sybear
            11.01.2022 13:50

            Это вообще дело десятое, откуда проверка взялась. Важно другое, есть возможность в слайсе ловить екшен из другой части приложения. Все, это все что надо знать, это ответ на твой изначальный вопрос.

            Редакс-тулкит сейчас это умолчание для приложений, использующих редакс.


            1. nin-jin
              11.01.2022 15:08
              +1

              - Пап, а почему небо голубое?
              - Ну там свет, преломление, все дела.
              - Так а голубое-то почему?
              - Слушай, на изначальный вопрос я ответил, а что там дальше уже дело десятое.


              1. sybear
                11.01.2022 15:24
                -2

                -Пап, а как сделать воду в стакане голубой?

                -Добавь в стакан краску, краска в ящике

                -Чем размешивать?

                -А чем размешивать, это уже дело десятое, можешь вилкой, можешь пикой.


      1. dmitryshelomanov
        11.01.2022 12:54
        -1

        все очень просто

        как раз для этого и придумали редакс


        1. nin-jin
          11.01.2022 13:24

          Для чего именно "этого" придумали редакс?


      1. faiwer
        11.01.2022 15:39
        +1

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

        useEffect(() => {
          resetFilters();
          await search(filters);
        }, [filters]);

        Где resetFilters это store.dispatch({ type: 'resetFilters' });, а filters это скажем store.blabla.filters. Где-то будет reducer, который ловит resetFilters и делает return { list: [] };


        Наверное ближайший аналог из мира observable:


        // somewhere
        st.filters = newFilters;
        
        // somewhere else
        st.filters.subscribe(async filters => {
          st.list = [];
          await search(filters);
        });

        В обоих случаях может потребоваться дополнительная обвязка которая выяснит что новый объект фильтров в точности равен старому (и тогда не задавать новое значение). Насколько я помню в $mol у вас это по-умолчанию. Но в большинстве observable решений либо этого нет, либо там shallow comparison. Но и там проблема решается (просто мануально).


        В случае redux такая же картинка. Нужно будет проверить что filters реально изменились. Это можно легко сделать в reducer-е.


        1. nin-jin
          11.01.2022 20:24
          +1

          Хех, а обещали без геморроя..


          1. faiwer
            11.01.2022 20:56
            +2

            В redux и без геморроя? Это едва ли.


    1. markelov69
      11.01.2022 20:07
      +3

      Что за глупости? Вы сделали 11 ошибок в слове MobX.


  1. Finesse
    11.01.2022 02:34
    +5

    Например, можно сравнить два варианта из сценария проверки на валидность текста. Есть input, есть стейт value и есть стейт isValid.

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

    function Form({required}) {
      const [value, setValue] = useState('')
      
      // Добавьте useMemo, если переживаете за производительность
      const isValid = validate(value, required)
      
      return (
        <div>
          <input
            value={value}
            onInput={event => setValue(event.target.value}
          />
          {!isValid && 'Не валидно'}
        </div>
      )
    }

    Если нужно не проводить валидацию только после взаимодействия пользователя с формой, добавьте стейт isTouched.


    1. Marat1403 Автор
      11.01.2022 07:53

      Если нужно не проводить валидацию только после взаимодействия пользователя с формой, добавьте стейт isTouched

      У вас есть два сценария: "пользовательский ввод" и "компонент был смонтирован". Так и опишите их. Поместите в didmount функцию валидации и тогда вам не понадобится в сценарии "пользовательский ввод" проставлять флажок для сценария "компонент был смонтирован". Оба сценария станут короче и изолированнее.


      1. Finesse
        11.01.2022 08:52
        +3

        В моём примере минимальное необходимое состояние и 1 короткий сценарий: пользовательский ввод. Остальное это декларативный рендер. Переключение флажка isTouched будет в том же коллбеке onInput. Кстати говоря, состояния value и isTouched зависимые, поэтому их можно поместить в один useState, чтобы при событии onInput происходил ровно 1 рендер.

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

        Поместите в didmount функцию валидации и тогда вам не понадобится в сценарии "пользовательский ввод" проставлять флажок для сценария "компонент был смонтирован".

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


  1. devlev
    11.01.2022 12:12

    Давайте рассмотрим сценарий побольше, нежели валидация инпута. Например, имеется список запланированных дел (типичная тудушка), у каждого запланированного действия есть кнопка «удалить», которая вызывает попап, который спрашивает «уверены ли вы, что хотите удалить запланированное действие».

    Возможно у вас компонент алерты засунут внуть тудушки?

    В своем проекте я делал алерты которые монтируются как отдельный компонент глобально почти что в самый root компонент. У алертов есть свой стор. Как только в этот стор тудушка кладет алерт он сразу отображается (алерты кстати точь в точь как вы и сказали сделаны: popout: { type, data }).



    Я так сделал из принципа DRY — потому что вызвать алерт может не только тудушка.

    В моей реализации компонент алерта общается с компонентом тудушки через стор (как вы сказали проброс пропсов сверху), но вот возникла задача как через стор прокидывать события из попапа обратно в компонент? (например событие удаления)

    И здесь я решил остановится на варианте что в самих алертах уже буду запускать удаление. Удаление обновит стор и значит обновит тудушку.


    1. Marat1403 Автор
      11.01.2022 12:53
      +1

      Я писал в конце про библиотеки для работы с порталами. Это выглядело бы примерно так:

      есть список:

      const ToDoList = (props) => {
      	const { items } = props
        
        const handleDelete = (item) => ...
      
      	return items.map((el) => <ToDo onDelete={handleDelete} {...el} />)
      }

      и есть запланированное действие:

      const ToDo = (props) => {
      	const { onDelete, ...el } = props
        const [isAlertOpen, setIsAlertOpen] = useState(false)
        
        ...
      
      	return (
        <div>
        ...
        {isAlertOpen && <Portal>
         <НекийКомпонентАлерт onDelete={() => onDelete(el)} />
      </Portal>}
      	...
      	</div>
        )
      }

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


      1. devlev
        11.01.2022 14:17

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

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


        1. Marat1403 Автор
          11.01.2022 15:57

          Для простоты можете использовать библиотеку "react-portal" - он прикрепляет содержимое к document.body. Это не плохо подойдёт для всплывающих окон. Для сценариев, где layout сложный и прикрепление компонента к document.body не отвечает потребностям, я написал библиотеку "react-jsx-portal" (ссылка). Вторая часть readme на русском. Я думаю многим она подойдёт.


      1. faiwer
        11.01.2022 15:26
        +1

        Удачи вам потом с таким подходом делать менеджер таких вот алертов. Ну когда там всякие анимации списков, лимиты и закреплённость. Я тоже таким баловался по первой. Потом пришёл к совсем другой схеме:


        • на верхнем уровне приложения есть HoC, который wrap-ает древо context-ом, в котором есть императивный метод showError(msg: ReactNode | Component, isFixed?: boolean): Promise<void> (и другие методы).
        • этот метод вызывается в тех самых useEffect или колбеках вроде onClick по мере необходимости ниже по древу
        • тот самый HoC сам всё это дело отображает. Сам решает в каком порядке показать. Когда удалить. Какие анимации задействовать и т.д.

        Это очень упрощает (раз в 10) работу с такими временные явлениями как alert-ы. Внизу по месту не требуется хранить никакого state-а. Не требуется никаких порталов. Точка входа одна. Возможностей реорганизовать менеджмент таких сообщений — какие угодно, никаких ограничений с точки зрения архитектуры.


        А самое главное почему это упрощает? Потому что эти алерты ничерта не декларативные по своей природе. Ну во всяком случае в тех проектах, с которыми я в последнее время работал. И если не натягивать сову на глобус, решая выдуманную проблему, то всё получается довольно удобно.


        Подобным же образом у нас работают разные shared диалоги. Такие которые связаны не с конкретным местом в бизнес логики приложения, а "общего" характера. Ну скажем в facebook это может быть какой-нибудь:


        const { openStoryPlayer } = useStoryPlayer();
        const onClick = () => openStoryPlayer(story.id);
        
        return <div {...{ onClick }}>...</div>;

        По клику откроется плеер сразу для нужно сущности. При желании можно даже сделать так, чтобы этот императивный метод возвращал promise. Скажем если нужно что-то сделать сразу по закрытию диалога\popover-а\popup-а.


        Стандартный декларативный React подход с тем чтобы везде по месту рендерить их top-down и руками handle-ить соответствующие стейты ничего кроме боле не вызывает. А учитывая что в 99% случаев активным может быть только 1 такой диалог, это ещё и бессмысленные страдания.


        1. devlev
          11.01.2022 15:35

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

          У меня была жесткая задача: 40 алертов а их надо по очереди показать, какие то алерты с возможностью выбора сценария, какие то сами схлопываются спустя время, а какие то при повторном вызове должне закрывать предыдущий. С помощью стора для алертов очень просто удалось это все порешать.

          на верхнем уровне приложения есть HoC, который wrap-ает древо context-ом, в котором есть императивный метод showError(msg: ReactNode | Component, isFixed?: boolean): Promise (и другие методы).

          Можете по подробнее объяснить этот момент?


          1. faiwer
            11.01.2022 15:49

            Можете по подробнее объяснить этот момент?

            Вы добавляете в global store новую запись, которую показывает по итогу некий <Alert/>, да? Примерно так:


            // in a component
            dispatch(addAlert("For the horde"));
            
            // in a reducer
            st.list.push({ msg: action.msg, id: genUniqId() });
            
            // in a <AlertManager/>
            {alerts.map(alert => <Alert key={alert.id} msg={alert.msg}/>}

            Если да, то у меня похожая схема, но без глобальных сторов. Те самые alert: Alert[] у меня храняется в самом <AlertManager/> в local state. А вместо dispatch компоненты ниже по древу просто вызывают:


            const showAlert = useContext(alertManagerContext);

            Концептуально тоже самое. Просто я не храню всё это в сторе. Я вообще сторонник того чтобы все global store-ы горели в аду в глобальном store лежали ТОЛЬКО те вещи, которые нужны в нескольких местах приложения сразу. Например данные о зарегистрированном пользователе. Но это уже совсем другой холивар.


            Ну и небольшой бонус хранения этого в локальном сторе менеджера — можно хранить ссылки на компоненты (которые будут .children для <Alert/>) или ссылки на JSX elements. В настоящий глобальный store такие вещи пихать не рекомендуется (обычно), т.к. такие вещи типа не очень сериализуемы. Но это уже тоже всякие догмы.


            1. devlev
              11.01.2022 16:07
              +1

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

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

              Вам спасибо за разъяснение!


  1. faiwer
    11.01.2022 15:13

    const [value, setValue] = useState('');
    const isValid = value.length !== ''; // or any other validator

    Всё. В подавляющем большинстве сценариев вам не нужно хранить производные значения в state. Это своего рода путь к ад.


    Конечно всё поломается, если вам нужен асинхронный валидатор. Но тут ничего нового. Асинхронные валидаторы при любой архитектуре это боль.


    Возможно вы возразите, дескать isValid должен быть true при выполнении не одного, а сразу двух условий (isTouched). Ежели так — то городите логику для поддержки isTouch. А isValid оставьте производным значением. Это сильно упрощает логику.


    Касательно форм и всех этих isValid, isTouched, isWhatever, resetForm, setError, asyncValidator и прочих штук из популярных библиотек по работе с формами… Они там неспроста. Как только вы захотите сделать форму по всем канонам идеального UX, вы упрётесь в то, что оно вынужденно такое всё сложное. Ибо сама задача очень сложная. И разумеется не надо её решать по месту в каком-то отдельно взятом useEffect или onClick. Закопаетесь.


    Но главный вывод — вещи которые можно высчитать из значений в любой момент, нужно делать ленивыми и декларативными. А не как в обоих ваших примерах. Это путь в никуда.


    1. nin-jin
      11.01.2022 15:40
      -2

       Асинхронные валидаторы при любой архитектуре это боль.

      @ $mol_mem
      isValid() {
        const mail = $mol_data_email( this.value() )
        const uri = '/check/email=' + encodeURIComponent( mail )
        return this.$.$mol_fetch.json( uri ).free
      }

       isTouched, isWhatever, resetForm, setError, asyncValidator и прочих штук из популярных библиотек по работе с формами… Они там неспроста. Как только вы захотите сделать форму по всем канонам идеального UX, вы упрётесь в то, что оно вынужденно такое всё сложное.

      На самом деле всякие isTouched - плохой UX. Проявляется обычно на предзаполненных формах (редактирование, автозаполнение), когда не видишь в форме ошибок, жмёшь "отправить" и получаешь в лучшем случае сообщение "ой, забыли вам сказать - поправьте вот тут, и вот тут".


      1. faiwer
        11.01.2022 15:53

        На самом деле всякие isTouched — плохой UX

        Ха. Вы предлагаете показывать голую форму сразу с ошибками "is required"? Юзер только открыл форму и уже видит 100500 ошибок валидации? В общем UX дизайнеры, которых я знаю, с вами не согласятся.


        @ $mol_mem

        К чему был этот пример и что он должен был показать? Заодно как решаются race?


        1. nin-jin
          11.01.2022 16:45

          Не надо call to action оформлять как ошибку и всё будет хорошо.

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


          1. faiwer
            11.01.2022 16:53

            Что такое "call to action"? Что покажет isValid на момент совершения запроса? Как происходит reset form? Ничего не понятно из этих пары строк.


            1. nin-jin
              11.01.2022 18:08

              Призыв к действию: "это поле заполнять обязательно". Не текстом, разумеется.

              На время запроса автоматически рисуется индикатор ожидания.

              Меняем редактируемую модель на новую - форма сбрасывается автоматически.


              1. Alexandroppolus
                11.01.2022 18:20

                Обязательные поля помечаются звездочкой, но всё равно надо покрасить красной рамкой, если не заполнены. Причем покрасить либо при условии isTouched (после потери фокуса), либо при попытке засабмитить (здесь тоже будет какой-то стейт, например, shownErrors).

                Почему? Да потому что хоть 100 звездочек нарисуй рядом с полем, юзер всё равно забудет, или понадеется, что "а вдруг поле на самом деле необязательное", если нет желания его заполнять.


                1. nin-jin
                  11.01.2022 19:38

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


                  1. nin-jin
                    11.01.2022 20:27

                    Примерно так это выглядит в деле, например:

                    https://mol.hyoo.ru/#!section=demos/demo=mol_form_demo_bids


              1. faiwer
                11.01.2022 18:22

                Не надо call to action оформлять как ошибку и всё будет хорошо.

                Не надо призыв к действию: "это поле заполнять обязательно" оформлять как ошибку и всё будет хорошо.

                Если честно я всё ещё ничего не понимаю. Какое действие? Какой призыв? Что такое оформлять как ошибку? Как оформлять по-другому? И откуда вообще мысль что вы, как разработчик, будете решать что и как оформлять как ошибку? :)


                upd. Раз 20 прочитал и кажется понял. Речь про то что "поле обязательно для заполнения" с вашей точки зрения это не ошибка, а некая просьба к юзеру и её надо показывать как-то иначе?


                Нууу… такая точка зрения имеет право на существование. Но мало относится к теме. Так почти никто не делает (или никто). Стандартный привычный UX трактует такое как ошибки.


                Меняем редактируемую модель на новую — форма сбрасывается автоматически.

                Понятно. Пересоздаём всю форму? Ну как вариант, но как-то шибко ультимативно. Может убежать куда-нибудь скролл, закрыться какой-нибудь spoiler, может ещё какое-нибудь не-value состояние отвалится.


                1. nin-jin
                  11.01.2022 19:41

                  Мы же говорили про идеальный UX, а не "привычный" говно-UX, который везде видим.

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