Pull to refresh

Прокачиваем React хуки с помощью FRP

Reading time9 min
Views4.3K

Освоив хуки, многие React-разработчики испытали эйфорию, наконец-то получив простой и удобный инструментарий, позволяющий реализовывать задачи существенно меньшим количеством кода. Но значит ли это, что предложенные из коробки стандартные хуки useState и useReducer — это все, что нам нужно для управления состоянием?


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


Есть некое поле для ввода, условно, имени. И есть кнопка, по клику по которой мы должны сделать запрос на сервер с введенным именем(некий поиск). Казалось бы, что может быть проще? Тем не менее, решение далеко не очевидно. Первая наивная имплементация:


const App = () => {
  const [name, setName] = useState('');
  const [request, setRequest] = useState();
  const [result, setResult] = useState();

  useEffect(() => {
    fetch('//example.api/' + name).then((data) => {
      setResult(data.result);
    });
  }, [request]);

  return <div>
    <input onChange={e => setName(e.target.value)}/>
    <input type="submit" value="Check" onClick={() => setRequest(name)}/>
    { result && <div>Result: { result }</div> }
  </div>;
}

Что тут не так? Если юзер, введя что-то в поле, отправит форму дважды, у нас сработает только первый запрос, т.к. при втором клике request не изменится и useEffect не сработает. Если представить, что наше приложение — сервис поиска билетов, и юзер вполне может через некоторые промежутки времени отправлять форму еще и еще, не внося изменений, то такая имплементация нам не подойдет! Использование name в качестве dependency для useEffect тоже неприемлемо, иначе форма будет отправляться сразу при изменении текста. Что же, придется проявить изобретательность.



const App = () => {
  const [name, setName] = useState('');
  const [request, setRequest] = useState();
  const [result, setResult] = useState();

  useEffect(() => {
    fetch('//example.api/' + name).then((data) => {
      setResult(data.result);
    });
  }, [request]);

  return <div>
    <input onChange={e => setName(e.target.value)}/>
    <input type="submit" value="Check" onClick={() => setRequest(!request)}/>
    { result && <div>Result: { result }</div> }
  </div>;
}

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


Давайте рассмотрим данный пример на теоретическом уровне, используя абстракцию потоков. Она очень удобна для описания состояния юзер-интерфейсов. Итак, у нас есть два потока: данные, введенные в текстовое поле(name$), и поток кликов по кнопке отправки формы(click$). Из них нам нужно создать третий, комбинированный поток запросов на сервер.


name$    __(C)____(Ca)_____(Car)____________________(Carl)___________
click$   ___________________________()______()________________()_____
request$ ___________________________(Car)___(Car)_____________(Carl)_

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


В нашем случае, click$ — это чистой воды сигнал: не важно, какое именно значние по нем протекает(undefined/true/Event/что угодно), важно только когда это происходит. Случай name$
противоположный: его изменения никак не влекут никаких изменений в системе, но само значение его может нам в определенный момент понадобиться. И вот из этих двух потоков нам нужно сделать третий, взяв от первого — время, от второго — значение.


В случае Rxjs, у нас есть практически готовый оператор для этого:


const names$ = fromEvent(...);
const click$ = fromEvent(...);
const request$ = click$.pipe(withLatestFrom(name$), map(([name]) => fromPromise(fetch(...))));

Однако, практическое использование Rx в Реакте может быть довольно неудобным. Более подходящий вариант — библиотека mrr, построенная на тех же функционально-реактивных принципах, что и Rx, но специально адаптированная для использования с Реактом по принципу "тотальной реактивности" и подключаемая в виде хука.


import useMrr from 'mrr/hooks';

const App = props => {
  const [state, set] = useMrr(props, {
    result: [name => fetch('//example.api/' + name).then(data => data.result), '-name', 'submit'],
  });

  return <div>
    <input value={state.name} onChange={set('name')}/>
    <input type="submit" value="Check" onClick={set('submit')}/>
    { state.result && <div>Result: { state.result }</div> }
  </div>;
}

Интерфейс useMrr схож на useState или useReducer: возвращает объект состояния(значения всех потоков) и сеттер для того, чтобы класть значения в потоки. А вот внутри все немного иначе: каждое поле состояния(=поток), кроме тех, в которые мы кладем значения прямо из событий DOM, описывается функцией и списком родительских потоков, изменение которых вызовет пересчет дочернего. При этом значения родительских потоков будут подставлены в функцию. Если же мы хотим просто получить значние потока, но не реагировать на его изменение, то мы пишем "минус" перед именем, как в случае с name.


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


В mrr у вас получится практически полное отделение "логики" от "шаблона": в JSX не придется писать какие-либо сложные императивные хендлеры. Все предельно декларативно: мы просто маппим DOM-событие на соответствующий поток, практческие без преобразований(для полей ввода автоматические извлекается значение e.target.value, если вы не укажете иного), а уже в структуре useMrr мы описываем, как из базовых потоков формируются дочерние. Таким образом, как в случае синхронных, так и асинхронных трансформаций данных, мы всегда можем легко проследить, каким образом формируется наше значение.


Сравнивая с Рх: нам даже не пришлось использовать дополнительные операторы: если в результате выполнение функций mrr получает промис, он автоматически дождется его резолва и положит полученные данные в поток. Также вместо оператора withLatestFrom мы использовали
пассивное слушание(знак минуса), которое более удобно. Представим, что кроме name нам нужно будет отправлять и другие поля. Тогда в mrr мы допишем еще один пассивно слушаемый поток:


result: [(name, surname) => fetch(...), '-name', '-surname', 'submit'],

А в Rx придется лепить еще один withLatestFrom с мапом, либо предварительно объединять name и surname в один поток.


Но вернемя к хукам и mrr. Более читабельная запись зависимостей, которая всегда показывает, как формируются данные, является, пожалуй, одним из главных преимуществ. Нынешний интерфейс useEffect принципиально не позволяет реагировать на потоки-сигналы, из-за чего
приходится придумывать разные выверты.


Другим моментом есть то, что вариант обычных хуков несет за собой лишние рендеры. Если юзер просто кликнул по кнопке, это не влечет еще никаких изменений в UI, которые реакту нужно отрисовать. Тем не менее, будет вызван рендер. В варианте с mrr, возвращаемый state будет проапдейчен только когда уже придет ответ с сервера. Экономия на спичках, скажете вы? Что ж, возможно. Но у меня лично сам принцип "в любой непонятной ситуации перерендеривайся", положенный в основу базовых хуков, вызывает неприятие.


Лишние рендеры означают и новое формирование ивент хендлеров. Кстати, и тут у обычных хуков все плохо. Мало того, что хендлеры императивны, так еще приходится при каждом рендере их заново генерировать. И использовать кеширование тут в полной мере не удастся, т.к. многие хендлеры должны быть замкнуты на внутренние переменные компонента. Хендлеры mrr более декларативны, а также в mrr уже встроенно их кеширование: set('name') будет сгенерирован лишь раз, и при последующих рендерах будет подставляться из кеша.


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



const App = () => {
  const [request, makeRequest] = useState();
  const [name, setName] = useState('');
  const [result, setResult] = useState(false);
  const [clicks, setClicks] = useState(0);

  useEffect(() => {
    fetch('//example.api/' + name).then((data) => {
      setResult(data.result);
    });
  }, [request]);

  return <div>
    <input onChange={e => setName(e.target.value)}/>
    <input type="submit" value="Check" onClick={() => { 
      makeRequest(!request);
      setClicks(clicks + 1);
    }}/><br />
    Clicked: { clicks }
  </div>;
}

Не очень приятно выглядит. Можно конечно вынести хендлер отдельной функцией внутри компонента. Читабельность повысится, но проблема перегенеривания функции при каждом рендере останется, как и проблема императивности. В сущности, это обычный процедурный код, несмотря на распостраненное мнение о том, что API Реакта постепенно изменяется в сторону функционального подхода.


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


На mrr:



const App = props => {
  const [state, set] = useMrr(props, {
    $init: {
      clicks: 0,
    },
    isValid: [name => fetch('//example.api/' + name).then(data => data.isValid), '-name', 'makeRequest'],
    clicks: [a => a + 1, '-clicks', 'makeRequest'],
  });

  return <div>
    <input onChange={set('name')}/>
    <input type="submit" value="Check" onClick={set('makeRequest')}/>
  </div>;
}

Более удобной альтернативой является useReducer, позволяя отказаться от императивности хендлеров. Но другие важные проблемы остаются: отсутсвие работы с сигналами (т.к. за сайд-эффекты будет отвечать тот же useEffect), а также худшая читабельность при асинхронных преобразованиях (проще говоря, проследить взаимосвязь между полями стора сложнее, из-за того же useEffect). Если в mrr граф зависимостей между полями состояния (потоками) сразу четко просматривается, в хуках вам придется немного побегать глазами вверх-вниз.


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


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


Централизованное логирование, дебаг. Поскольку в mrr все потоки содержатся в одном хабе, для дебага достаточно добавить один флаг:


const App = props => {
  const [state, set] = useMrr(props, {
    $log: true,
    $init: {
      clicks: 0,
    },
    isValid: [name => fetch('//example.api/' + name).then(data => data.isValid), '-name', 'makeRequest'],
    clicks: [a => a + 1, '-clicks', 'makeRequest'],
  });
...

После чего все изменения потоков будут отображаться в консоли. Для обращения ко всему состоянию(т.е. к текущим значениям всех потоков) есть псевдопоток $state:


a: [({ name, click, result }) => { ... }, '$state', 'click'],

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


Работа со временем. Помните два аспекта потоков: значение и время срабатывания, гармония и ритм? Так вот, работа с первым в обычных хуках довольно проста и удобна, а вот со вторым — нет. Под работой со временем я подразумеваю образование дочерних потоков, "ритм" которых отличается от родительского. Это прежде всего разного рода фильтры, дебаунсы, тротлы и т.д. Все это вам скорее всего придется реализовывать самим. В mrr вы можете использовать готовые операторы из коробки. Джентльменский набор mrr уступает многообразию операторов Rx, зато имеет более интуитивно понятный нейминг.


Межкомпонентное взаимодействие. Помнится, в Редаксе считалось хорошей практикой создание только одного стора. Если же мы будем использовать useReducer во многих компонентах,
возможна проблема с организацией взаимодействия сторов. На mrr потоки могут свободно "перетекать" из одного компонента в другой как вверх, так и вниз по иерархии, но это не создаст проблем вследствие декларативности подхода. Более подробно
эта тема, а также остальные особенности API mrr, описаны в статье Акторы+FRP в Реакте


Выводы


Новые реактовские хуки прекрасны и упрощают нашу жизнь, однако у них есть некоторые недостатки, которые может устранить более высокоуровневый хук общего назначения (управление состоянием). В качестве такого был предложен и рассмотрен useMrr из функционально-реактивной библиотеки mrr.


Проблемы и их решения:


  • ненужные пересчеты данных при каждом рендере(в mrr отсутствуют благодаря push-based реактивности)
  • лишние рендеры тогда, когда изменение состояние не влечет за собой изменение UI
  • плохая читабельность кода с асинхронными преобразованиями(по сравнению с синхронными). В mrr асинхронный код не уступает синхронному в читабельности и выразительности. Большинство проблем, рассмотренных в недавней статье о useEffect, на mrr в принципе невозможны
  • императивные хендлеры, не всегда поддающиеся кешированию(в mrr автоматически кешируются, почти всегда могут быть закешированы, декларативны)
  • одновременное использование useState и useReducer может создавать неуклюжий код
  • отсутствие инструментария для преобразования потоков во времени(debounce, throttle, race condition)

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


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


Данная статья призвана не навязать какие-то конкретные взгляды, но скорее привлечь внимание к проблеме. Уверен, что существуют или сейчас создаются другие решения, которые могут стать достойной альтернативой, но пока не стали широко известны. Существенно изменить ситуацию может также грядущее API React Cache. Буду рад критике и обсуджению в комментариях.


Заинтересовавшиеся могут также посмотреть выступление по этой теме на kyivjs 28 марта.

Tags:
Hubs:
+11
Comments9

Articles