Стоит ли использовать Redux с React Hooks

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

Хайп уже почти закончился: одни прониклись философией хуков и используют их везде, другие еще не дошли до их использования, а есть и те, кто попробовал и решил, что эта концепция не для него. Но глупо спорить, React Hooks все больше и больше внедряются в экосистему реакта. Если у вас есть библиотека на реакте и в ней нет хуков, то что-то здесь не так. Документация многих пакетов переписывается на примеры с использованием хуков как основной способ использования (formik, react-dnd). На мой взгляд, это дело времени, пока все не начнут использовать хуки, пусть даже неявным образом.

React-redux, начиная с версии 7.1 добавили долгожданную поддержку хуков. На самом деле это произошло давно, но в своем проекте я решил на днях посмотреть, насколько будет удобно их использовать. Внедрение хуков означало, что теперь можно избавиться от connect (компонента высшего порядка) и использовать Redux внутри функциональных компонентов.

В посте рассмотрим, как начать использовать React Hooks с редаксом, какие могут возникнуть проблемы и постараюсь ответить на главный вопрос: "Стоит ли в своих проектах избавляться от connect в пользу хуков"?

Что такое React Hooks #

В реакте 16.8 появились хуки. Они позволили использовать такие вещи, как состояние, возможности методов жизненного цикла в функциональных компонентах, которые ранее были доступны только в компонентах на классах.

Например, у нас есть компонент со состоянием, написанный на классе:

class AwesomeComponent extends React.Component {
    state = {
        counter: 0,
    };

    onClick = () => {
        this.setState({ count: this.state.count + 1 });
    };

    render() {
        return (
            <div>
                <p>Count: {this.state.count}</p>
                <button onClick={this.onClick}>Add +1</button>
            </div>
        );
    }
}

Сейчас этот компонент может быть переписан на хуки, например, так:

import { useState } from 'react';

function AwesomeComponent() {
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(count + 1)}>Add +1</button>
        </div>
    );
}

Думаю вы согласитесь, что код с хуками выглядит более лаконично. И он позволяет в функциональные компоненты добавлять фичи, ранее недоступные без переписывания компонента на класс. По хукам есть отличная документация на официальном сайте (на английском и русском языках), поэтому если хотите разобраться в них, рекомендую к прочтению.

Как использовать Redux с хуками #

На самом деле очень просто! В библиотеки react-redux есть уже готовые useSelector и useDispatch, которые можно использовать вместо коннект.

useSelector - это аналог mapStateToProps. Хук принимает на вход селектор - метод, который принимает redux state и возвращает из него необходимые данные.

useDispatch - замена для mapDispatchToProps, только в довольно упрощенном виде. Хук возвращает dispatch метод из редакса, с помощью которого можно диспатчить экшены. С одной стороны это избавляет нас от action creators, с другой - ломает уже принятую парадигму не использовать dispatch напрямую.

У меня сразу возник вопрос, зачем мне нужен dispatch, если у меня есть заготовленные action creators. В документации я увидел следующее:

Хук useActions

Как оказалось, изначально хук useActions был добавлен в альфу, но потом его выпилили из-за комментария Дена Абрамова (раз, два). Ден сказал о том, что паттерн action creators as a props добавляет лишние абстракции и сложность в мире хуков и привел хороший пример:

You don't useFunction(sum, 2, 2) to obtain a boundSum and then call boundSum. You just call sum(2, 2). This is the same.

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

import { useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';

export function useActions(actions, dependencies = []) {
    const dispatch = useDispatch();

    return useMemo(
        () => actions.map(a => bindActionCreators(a, dispatch)),
        [dispatch, ...dependencies]
    );
}

В любом случае давайте перепишем компонент с connect на хуки. Первоначально компонент может выглядеть так:

import React from 'react';
import { connect } from 'react-redux';
import { incrementCount } from './store/counter/actions';

export function AwesomeReduxComponent(props) {
    const { count, incrementCount } = props;

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={incrementCount}>Add +1</button>
        </div>
    );
}

const mapStateToProps = state => ({ count: state.counter.count });
const mapDispatchToProps = { incrementCount };

export default connect(mapStateToProps, mapDispatchToProps)(AwesomeReduxComponent);

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

import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { incrementCount } from './store/counter/actions';

export const AwesomeReduxComponent = () => {
    const count = useSelector(state => state.counter.count);
    const dispatch = useDispatch();

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => dispatch(incrementCount())}>Add +1</button>
        </div>
    );
};

Выглядит более просто, чем с использованием функции connect, props компонента не смешиваются со свойствами из редакса. Также большое преимущество, что теперь не нужно оборачивать свои компоненты в HOC-и, тем самым избавляясь от connect hell:

connect hell

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

В документации хорошо описаны доступные хуки, рекомендую почитать.

Redux hooks против connect #

Преимущества хуков в рамках редакса мы уже рассмотрели, теперь поговорим о недостатках. На самом деле некоторые gotchas описаны в документации в разделе usage warnings:

  • useSelector использует по умолчанию строгое равенство для сравнения объектов, которые возвращает селектор (из-за этого в случае возврата нового объекта компонент постоянно будет перерисовываться) и нужно использовать свой метод для сравнения. Или можно написать свой хук:
import { useSelector, shallowEqual } from 'react-redux';

export function useShallowEqualSelector(selector) {
   return useSelector(selector, shallowEqual);
}

И использовать его:

export const AwesomeReduxComponent = () => {
    // Хук необходим, если селектор возвращает новый объект
    const { count } = useShallowEqualSelector(state => {
        count: state.counter.count;
    });
    const dispatch = useDispatch();

    return <div />;
};
  • К тому же, в отличие от connect, хук useSelector не предотвращает повторный ререндер компонента, когда перерисовывается родитель, даже если пропы не изменились. Поэтому для оптимизации стоит использовать React.memo():
export const AwesomeReduxComponent = React.memo(() => {
    // Хук необходим, если селектор возвращает новый объект
    const { count } = useShallowEqualSelector(state => {
        count: state.counter.count;
    });
    const dispatch = useDispatch();

    return <div />;
});
  • При передаче callback-a с dispatch дочерним компонентам следует оборачивать метод в useCallback, что бы дочерние компоненты не рендерились без необходимости:
export const AwesomeReduxComponent = React.memo(() => {
    // Хук необходим, если селектор возвращает новый объект
    const { count } = useShallowEqualSelector(state => {
        count: state.counter.count;
    });
    const dispatch = useDispatch();
    const onClick = useCallback(
        () => dispatch(incrementCount()),
        [dispatch]
    );

    return <div />;
});

Уже не выглядит так лаконично, не правда ли? На тестовом проекте у меня получилось следующее:

function SneakersPage() {
    const { popular } = useSelector(getHomepage);
    const isLoading = useSelector(isLoadingSelector);
    const data = useSelector(getShoes);
    const dispatch = useDispatch();
    const fetchShoes = React.useCallback(
        slug => dispatch(fetchShoesActionCreator(slug)),
        [dispatch]
    );

    // ...
}

К компоненту добавилось 8 дополнительных строчек кода. Как это будет выглядеть в больших проектах, 20-30 дополнительных строчек кода? Думаю в этот момент захочется переписать все обратно. Но есть решение: в таком случае можно использовать композицию хуков - все хуки выносить в отдельный хук для компонента. Выглядеть это будет как-то так:

function SneakersPage() {
    const {
        popular,
        isLoading,
        data,
        dispatch,
        fetchShoes
    } = useSneakersPage();

    // ...
}

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

Есть еще несколько моментов:

  • Усложнение тестирования. Для тестирования компонента придется всегда создавать стор и оборачивать компонент в ReduxProvider, т.е. придется писать интеграционные тесты. В случае с connect, мы можем экспортировать компонент и тестировать его независимо.

  • Нарушение принципа единой ответственности. Компонент становится ответственным за слишком многое, тем самым становится более сложным. Дядюшка Боб будет недоволен.

  • Дебаг. В своем тестовом приложении я могу изменять значения пропсов компонента в dev tools (которые приходят из connect-a) и смотреть, как компонент будет выглядеть в таком случае. Например, ниже, я меняю проп isLoading и элементы с кроссовками меняются на заглушки:

Изменение свойств у компонента

Как дела с хуками? С хуками у нас следующая картина:

Неинформативная информация о хуках

К сожалению, нет возможности просматривать текущие значения и изменять их как пропсы. Есть только понимание, что используются три useSelector и useDispatch. Как воркэраунд, можно вынести хуки в компонент высшего порядка, но в таком случае смысл использования хуков пропадает.

Итого #

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

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

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

Ссылки по теме #

Оставайтесь на связи
Чтобы не пропустить новые посты или анонсы проектов, которые я делаю, вы можете присоединиться к телеграм каналу. А так же оставаться на связи, задавать вопросы или просто вместе обсуждать различные инженерные темы.
Присоединиться

Вам может быть интересно

Почему я не использую NextJS

14/10/2022
О каких технологиях вы задумываетесь, когда речь заходит про SSR и React?
106

Работа с cookies в универсальных приложениях на react

04/05/2019
Принцип работы с cookies (куками) в универсальных приложениях на react
93

Архитектура фронтенда на основе вертикальных слайсов

28/05/2023
Обсудим подход вертикальных слайсов через технические слои во фронтенде
106