Смогут ли React-хуки заменить Redux?

Перевод статьи Do React Hooks Replace Redux? с сайта medium.com для css-live.ru, автор — Эрик Эллиотт

Вкратце: хуки хороши, но Redux они не заменят

«Мандаринка» — снимок Малкольма Карлоу (CC-BY-2.0)

Как только API React-хуков вышел, стало появляться много вопросов о том, сможет ли он заменить Redux.

Как по мне, хуки довольно слабо пересекаются с Redux. Они не дают никакого нового волшебства для состояний, а лишь улучшают API для того, что уже было в React. Но с API хуков пользоваться нативным React-инструментарием для состояния стало гораздо удобнее, а поскольку он – еще и более простая замена для классовой модели, мне теперь гораздо чаще удается к месту использовать состояние компонента

Чтобы мои слова стали понятнее, давайте сначала разберемся, зачем вообще нам бывает нужен Redux.

Что такое Redux?

Redux – библиотека и архитектура для предсказуемого управления состоянием, которая легко интегрируется с React.

Вот основные плюсы Redux:

  • Детерминированное разрешение состояния (дающее возможность получить детерминированное представление при объединении с чистыми компонентами).
  • Транзакционное состояние.
  • Изоляция управления состоянием от ввода-вывода и побочных эффектов.
  • Единый источник достоверных данных для состояния приложения.
  • Легкий общий доступ к состоянию для разных компонентов.
  • Мониторинг транзакций (автологирование объектов action).
  • Отладка с помощью путешествия во времени.

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

Что такое React-хуки?

С React-хуками можно использовать возможности жизненных циклов React без классов и соответствующих методов жизненных циклов React-компонентов. Они появились в React 16.8.

Вот основные плюсы React-хуков:

  • Использование состояния и привязка к жизненным циклам компонента без помощи класса.
  • Хранение связанной логики в компоненте в одном месте вместо разделения ее по разным методам жизненного цикла.
  • Использование одного и того же универсального поведения в разных компонентах независимо от их реализации (по аналогии с рендер-пропсами).

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

С инструментами вроде API хуков react-redux и React-хуком useReducer не нужно выбирать что-то одно. Используйте оба. Используйте и то, и другое, вместе и в сочетании.

Что заменяют хуки?

С появлением API хуков я больше не использую:

Что хуки не заменяют?

Я по-прежнему часто использую:

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

Когда использовать хуки

Не для каждого приложения и не для каждого компонента бывает нужен Redux. Если у приложения одно-единственное представление, оно не сохраняет и не загружает состояние, и у него нет асинхронного ввода/вывода, я не вижу особого смысла, чтобы добавлять сложность Redux.

Аналогично, если компонент:

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

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

import React, { useState } from 'react';
import t from 'prop-types';
import TextField, { Input } from '@material/react-text-field';
const noop = () => {};
const Holder = ({
  itemPrice = 175,
  name = '',
  email = '',
  id = '',
  removeHolder = noop,
  showRemoveButton = false,
}) => {
  const [nameInput, setName] = useState(name);
  const [emailInput, setEmail] = useState(email);
const setter = set => e => {
    const { target } = e;
    const { value } = target;
    set(value);
  };
return (
    <div className="row">
      <div className="holder">
        <div className="holder-name">
          <TextField label="Name">
            <Input value={nameInput} onChange={setter(setName)} required />
          </TextField>
        </div>
        <div className="holder-email">
          <TextField label="Email">
            <Input
              value={emailInput}
              onChange={setter(setEmail)}
              type="email"
              required
            />
          </TextField>
        </div>
        {showRemoveButton && (
          <button
            className="remove-holder"
            aria-label="Remove membership"
            onClick={e => {
              e.preventDefault();
              removeHolder(id);
            }}
          >
            ×
          </button>
        )}
      </div>
      <div className="line-item-price">${itemPrice}</div>
      <style jsx>{cssHere}</style>
    </div>
  );
};
Holder.propTypes = {
  name: t.string,
  email: t.string,
  itemPrice: t.number,
  id: t.string,
  removeHolder: t.func,
  showRemoveButton: t.bool,
};
export default Holder;

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

const [nameInput, setName] = useState(name);
const [emailInput, setEmail] = useState(email);

Можно заметить, что есть генератор действия removeHolder, приходящий в пропсах из Redux. Вполне можно сочетать одно с другим.

В подобных случаях и раньше можно было использовать локальное состояние компонента, но до React-хуков у меня бы наверняка возник соблазн засунуть его в Redux и всё равно вытащить состояние из пропсов.

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

Поскольку я использовал Redux во всех своих сложных приложениях, выбор был простым: в (почти) любой ситуации юзай Redux!

Теперь выбор по-прежнему простой:

Состояние компонента – для состояния отдельных компонентов, Redux – для состояния всего приложения.

Когда использовать Redux

Еще один частый вопрос: «Нужно ли всё класть в Redux? Не сломается ли без этого отладка с помощью путешествия во времени?»

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

Другими словами, не бойтесь использовать Redux, но только когда для этого есть основания.

Redux полезен, если ваш компонент:

  • Использует ввод-вывод, например сеть или API устройства.
  • Сохраняет и загружает состояние.
  • Его состоянием пользуются другие компоненты извне.
  • Работает с любой бизнес-логикой или обработкой данных, которые задействованы в других частях приложения.

Вот ещё пример из приложения TDDDay:

import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { compose } from 'ramda';
import page from '../../hocs/page.js';
import Purchase from './purchase-component.js';
import { addHolder, removeHolder, getHolders } from './purchase-reducer.js';
const PurchasePage = () => {
  // Это можно использовать вместо
  // mapStateToProps и mapDispatchToProps
  const dispatch = useDispatch();
  const holders = useSelector(getHolders);
const props = {
    // Используйте композицию функций, чтобы объединять генераторы действий
    // с их отправкой. Подробности читайте в книге «Композиция программного обеспечения»
    addHolder: compose(
      dispatch,
      addHolder
    ),
    removeHolder: compose(
      dispatch,
      removeHolder
    ),
    holders,
  };
return ;
};
// `page` —  компонент высшего порядка, составленный из множества
// других компонентов высшего порядка с помощью композиции функций.
export default page(PurchasePage);

Этот компонент не обрабатывает никакой DOM. Это презентационный компонент. Он подключается к Redux с помощью API хуков React-Redux.

Он использует Redux, поскольку данные из этой формы используются в других частях UI, и нам понадобится сохранить данные в базу, как только процедура покупки закончится.

Состояние, за которое он отвечает, используется несколькими компонентами, а не локализовано в одном, оно постоянно, а не временно, и потенциально охватывает несколько страниц или сессий. Для всего этого локальное состояние компонента не подходит, если только не построить свою библиотеку для хранения состояния поверх React API — и это намного сложнее, чем просто использовать Redux.

В будущем API задержки (Suspense) в React сможет помочь с сохранением и загрузкой состояния. Подождем широкой поддержки API Suspense, посмотрим, сможет ли он заменить паттерны сохранения/загрузки в Redux. С Redux можно аккуратно отделять побочные эффекты от остальной логики компонента без имитации сервисов ввода-вывода. (Изоляция эффектов — вот почему я предпочитаю redux-saga вместо thunks). Чтобы на равных конкурировать с такими задачами в Redux, API React нужна возможность изоляции API эффектов.

Redux — это архитектура

Redux — гораздо больше (а часто гораздо меньше), чем библиотека для управления состоянием. Он ещё по сути и подмножество архитектуры Flux, у которой более специфический подход к тому, как вносить изменения в состояние. У меня есть ещё статья, в которой я детально описываю архитектуру Redux.

Я часто использую редьюсеры в стиле Redux, когда требуется сложное состояние компонента, но мне не нужна библиотека Redux. И я использую объекты action в стиле Redux (и даже инструменты Redux вроде Autodux и redux-saga), чтобы отправлять действия в приложениях на Node, вообще не импортируя сам Redux.

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

Это всё здорово, если вы захотите больше использовать локальное состояние компонентов с помощью API хуков, а не хвататься чуть что за Redux.

React предоставляет хук useReducer, который работает с вашим редьюсером в стиле Redux. Это здорово для нетривиальной логики состояния, зависимого состояния, и так далее. Если вы считаете, что в вашей задаче временное состояние будет храниться в пределах одного компонента, то можете использовать архитектуру Redux, но управлять состоянием с помощью хука useReducer, а не библиотеки Redux.

Если потом понадобится сделать состояние постоянным или разделить его с другими компонентами, то 90% уже сделано. Всё что осталось — подключить компонент и добавить редьюсер к вашему хранилищу Redux.

Ещё вопросы и ответы

«Не сломается ли детерминизм, если не класть всё в Redux?»

Нет. По факту, Redux тоже не навязывает детерминизм. Это делает соглашение. Хотите, чтобы состояние в Redux было детерминированным, используйте чистые функции. И чтобы временное состояние компонента было детерминированным, тоже используйте чистые функции.

«Разве нам не нужен Redux как единственный источник правды?»

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

Это значит, что можно выбрать, что хранить в Redux, а что в состоянии компонента. Можно также получать состояние из других источников вроде браузерного API для текущего адреса страницы.

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

Всё, что вы кладёте в Redux-состояние, должно всегда из него же и читаться. Для любого состояния в Redux единственным источником правды должен быть Redux.

Нормально класть всё в Redux, если нужно. Могут быть вопросы с производительностью, если состояние должно часто обновляться, или если в компонентах активно используется зависимое состояние. Не стоит заниматься оптимизацией преждевременно, но если уж взялись за нее, то попробуйте и с Redux, и без него, и посмотрите, есть ли разница. Профилируйте, и помните о модели производительности RAIL.

«Что лучше использовать, функцию connect() из пакета react-redux или API хуков?»

По ситуации. Функция connect создаёт многоразовый компонент высшего порядка, тогда как API хуков заточен под интеграцию с одним компонентом. Нужно подключить такие же пропсы хранилища к другим компонентам? Берите connect. В остальных случаях мне больше нравится синтаксис API хуков. К примеру, представьте, что у вас есть компонент, который проверяет, какие действия разрешены для пользователя, а какие нет:

import { connect } from 'react-redux';
import RequiresPermission from './requires-permission-component';
import { userHasPermission } from '../../features/user-profile/user-profile-reducer';
import curry from 'lodash/fp/curry';

const requiresPermission = curry(
  (NotPermittedComponent, { permission }, PermittedComponent) => {
    const mapStateToProps = state => ({
      NotPermittedComponent,
      PermittedComponent,
      isPermitted: userHasPermission(state, permission),
    });

    return connect(mapStateToProps)(RequiresPermission);
  },
);

export default requiresPermission;

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

import NextError from 'next/error';
import compose from 'lodash/fp/compose';
import React from 'react';
import requiresPermission from '../requires-permission';
import withFeatures from '../with-features';
import withAuth from '../with-auth';
import withEnv from '../with-env';
import withLoader from '../with-loader';
import withLayout from '../with-layout';

export default compose(
  withEnv,
  withAuth,
  withLoader,
  withLayout(),
  withFeatures,
  requiresPermission(() => , {
    permission: 'admin',
  }),
);

Чтобы использовать это:

import compose from 'lodash/fp/compose';
import adminPage from '../HOCs/admin-page';
import AdminIndex from '../features/admin-index/admin-index-component.js';

export default adminPage(AdminIndex);

API компонента высшего порядка хорошо подходит для этой задачи, и он лаконичнее API хуков (для него требуется меньше кода), но чтобы прочитать API функции connect, нужно помнить, что он принимает mapStateToProps в качестве первого аргумента, и mapDispatchToProps — в качестве второго, и учитывать, что он может принимать функции или литералы объектов, и уметь различать их поведение. Также нужно помнить, что он каррируется, но не автоматически.

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

«Если синглтон — антипаттерн, а Redux — синглтон, то не антипаттерн ли Redux?»

Нет. Синглтон — «запашок» кода, который может указывать на общее изменяемое состояние — вот оно-то действительно антипаттерн. Redux предотвращает общее изменяемое состояние с помощью инкапсуляции (вам не следует мутировать состояние приложения вне редьюсеров, обработку обновления состояния берёт на себя Redux) и передачи сообщений (только отправленные объекты action могут вызывать обновления состояния).

Следующие шаги

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

Изучайте юнит-тестирование React-компонентов, и раз уж зашла речь о тестировании, почитайте про разработку через тестирование (TDD) на TDDDay.com.

Эрик Эллиотт — автор книг «Композиция программного обеспечения» и «Программирование JavaScript-приложений». Как сооснователь EricElliottJS.com и DevAnywhere.io он обучает разработчиков необходимым навыкам разработки программного обеспечения. А также формирует и консультирует команды разработчиков для крипто-проектов, и он участвовал в разработке программ для Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC, и лучших артистов, включая Usher, Frank Ocean, Metallica, и многих других.

Он ведет удаленный образ жизни с самой красивой женщиной в мире.

P.S. Это тоже может быть интересно:

Оставить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Ваш E-mail не будет опубликован

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