Саша БеспоясовФотография автора

От Редакса к хукам

В Реакте в версии 16.7.0 появились Hooks (дальше по тексту — хуки). Это API, которое позволяет использовать локальный стейт без использования классов. И среди них есть один, который, как мне кажется, может заменить собой Редакс.

В этой статье я предполагаю, что вы знаете разницу между функциональными компонентами и классами, в курсе о локальном стейте и жизненном цикле компонентов и о том, как работает Редакс. Без этого вникнуть будет трудно, но всё в одну статью я бы не уместил, так что вот ¯\_(ツ)_

Что за хуки?

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

Например, здесь мы используем хук useState, чтобы создать и использовать переменную counter:

import React, { useState } from "react";

const SimpleComponent = () => {
  const [counter, setCounter] = useState(0);
  // в первый раз значение counter будет равно тому, что мы передаём в useState
  // затем — тому, что мы установим через setCounter
  return <div>{counter}</div>;
};

useState возвращает кортеж из значения и функции, которая будет это значение обновлять.

Из-за того, что в одном компоненте можно использовать несколько хуков по несколько раз, у них есть ограничения и правила. Подробнее об этом написано в документации и рассказано в докладе на Реакт-конфе.

useReducer

useReducer — это хук, который по принципу работы схож с редьюсерами из Редакса.

const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  // initialState — начальное состояние
  // reducer — функция, которая принимает state и action
  //           и обрабатывает изменение состояния
  //
  // state — текущее состояние
  // dispatch — функция, которая будет дёргать экшены,
  //            чтобы обновить состояние

  return <div>Hello world</div>;
};

По принципу работы это и есть Редакс. Проблема только в том, что переменные state и dispatch находятся внутри области видимости функции App, а значит использовать этот редьюсер в других компонентах у нас не получится.

...Если только мы не используем контекст.

Context API

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

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

Иллюстрация проблемы Prop drilling
Prop drilling, carlrippon.com

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

Ту же проблему решает и контекст в Реакте. Мы создаём контекст, через провайдер указываем, что именно нужно хранить и передавать, а через консьюмер — в каком компоненте эти значения забирать и использовать:

import { createContext } from "react";
// создаём контекст
const StoreContext = createContext();

const App = () => (
  // через провайдер в свойстве value указываем значение,
  // которое нам надо хранить и как-то использовать в других компонентах
  <StoreContext.Provider value={{ meaningOfLife: 42 }}>
    <OtherComponent />
  </StoreContext.Provider>
);

const OtherComponent = () => {
  // через консьюмер получаем доступ к значению
  return (
    <StoreContext.Consumer>
      {({ meaningOfLife }) => <div>{meaningOfLife}</div>}
    </StoreContext.Consumer>
  );
};

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

Пример

Я написал простенькое приложение с использованием Редакса и с использованием контекста и хуков. Это счётчик, значение которого можно менять кнопками, либо меняя значение в инпуте, а также обнулять его нажатием на кнопку из другого компонента.

Приложение для примера
Приложение для примера

По структуре оно будет состоять из корневого компонента App, компонента формы Form и ещё одного компонента с кнопкой внизу Display. Схематично можно изобразить так:

Структура вложенности компонентов
Структура вложенности компонентов

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

С использованием Редакса

Вначале создадим хранилище и редьюсер, который будет заниматься его обновлением:

// reducers.js
import { combineReducers } from "redux";

const app = (state, action) => {
  switch (action.type) {
    case "PLUS":
      return { ...state, counter: state.counter + 1 };

    case "MINUS":
      return { ...state, counter: state.counter - 1 };

    case "MAGIC":
      return { ...state, counter: Math.floor(Math.random() * 100) };

    case "CHANGE":
      return { ...state, counter: +action.value };

    case "RESET":
      return { ...initialState };

    default:
      return state;
  }
};

export default combineReducers({ app });

// index.js
import { createStore } from "redux";
import rootReducer from "./reducers";
import App from "./App";

// создаём хранилище
const store = createStore(rootReducer);

// используем через провайдер
render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("app"),
);

Дальше создадим экшены, которые будут вызываться событиями кнопок и инпута:

// actions.js
export const plus = () => ({ type: "PLUS" });
export const minus = () => ({ type: "MINUS" });
export const magic = () => ({ type: "MAGIC" });
export const reset = () => ({ type: "RESET" });
export const change = (e) => ({
  value: e.target.value,
  type: "CHANGE",
});

Чтобы привязать какой-то компонент к хранилищу, используем connect:

import { connect } from "react-redux";
import { reset } from "./actions";

// app — часть хранилища;
// reset — экшен;
// всё это мы привязали через connect перед экспортом ниже
const Display = ({ app, reset }) => {
  const { counter } = app;

  return (
    <footer>
      <p>Another component knows that counter equals to {counter} as well!</p>
      <p>
        It even can
        <button onClick={reset}>reset the coutner</button>
      </p>
    </footer>
  );
};

// мапим свойства из хранилища и экшены
// на пропсы компонента
export default connect((state) => ({ app: state.app }), { reset })(Display);

В результате приложение будет работать так.

Контекст + хуки

Теперь напишем то же самое без использования Редакса. Используем useReducer:

// store.js
export const initialState = { counter: 0 };

// редьюсер точно такой же, как в прошлый раз
export const reducer = (state, action) => {
  // ...
};

// index.js
import { reducer, initialState } from "./store";

const App = () => {
  // создаём в корневом компоненте хранилище
  // и функцию для его обновления
  const [state, dispatch] = useReducer(reducer, initialState);
  return <div></div>;
};

Чтобы пробросить значения из хранилища компонентам, воспользуемся контекстом:

// context.js
import { createContext } from "react";
export const StoreContext = createContext();

// index.js
import { reducer, initialState } from "./store";
// импортируем созданный контекст
import { StoreContext } from "./context";

const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState);

  // используем провайдер, чтобы передать в контекст
  // хранилище и функцию для обновления
  return (
    <StoreContext.Provider value={{ dispatch, state }}>
      <Form />
      <Display />
    </StoreContext.Provider>
  );
};

export default App;

Чтобы привязать какой-то компонент, используем консьюмер:

import React from "react";
// импортируем контекст
import { StoreContext } from "./context";
// экшены точно такие же, как в прошлый раз
import { reset } from "./actions";

const Display = () => (
  // получаем доступ к тому, что хранится в контексте
  <StoreContext.Consumer>
    // в нашем случае — state и dispatch
    {({ state, dispatch }) => (
      <footer>
        // используем state, чтобы вывести значение счётчика
        <p>{state.counter}</p>
        // используем dispatch, чтобы дёрнуть экшен
        <button onClick={() => dispatch(reset())}>reset</button>
      </footer>
    )}
  </StoreContext.Consumer>
);

export default Display;

А ещё можно сделать код чище, заменив консьюмер на useContext:

import React, { useContext } from "react";
import { StoreContext } from "./context";
import { reset } from "./actions";

const Display = () => {
  // вызываем useContext, передавая аргументом нужный контекст
  const { state, dispatch } = useContext(StoreContext);

  return (
    // убираем консьюмер
    <footer>
      <p>{state.counter}</p>
      <button onClick={() => dispatch(reset())}>reset</button>
    </footer>
  );
};

export default Display;

И работает оно точно так же.

А чо по весу и перформансу?

Я не удивился, когда бандл ужался на 12 кБ: с Редаксом — 166, без него — 154. Это логично, меньше зависимостей — меньше вес.

А вот прирост в скорости обработки экшенов и отрисовки меня слегка удивил. Я проводил измерения с помощью console.time и performance.measure. Средние значения за 100 итераций вышли такими:

 console.timeperformance.measure
Redux12 мс13 мс
Context + hooks9 мс8 мс

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

Минусы

Вызов экшенов стал чуть более многословным из-за прямого использования dispatch. И если работать с контекстом без useContext, придётся использовать паттерн render-prop, из-за чего может подняться щит-сторм ¯\_(ツ)_

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

Но если серьёзно, хуки — пока что в стадии RFC, и возможно многое поменяется. Поэтому переписывать свои приложения на них не советует даже Дэн Абрамов. То есть это экспериментальная фигня.

Хотя выглядит всё равно заманчиво :–)

Ссылки

Документация

Доклады

Статьи со сравнениями

Измерения перформанса

Предыдущий пост: Рим, октябрь 2018Следующий пост: Хватит быть славным парнем. Роберт Гловер