От Редакса к хукам?
В Реакте в версии 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. Схематично это выглядит, как на картинке слева:

Редакс помимо работы с состоянием решал и эту проблему тоже. С ним мы создаём лишь одно хранилище, к которому может обращаться любой компонент. Таким образом нам не требуется пробрасывать свойства через всё дерево.
Ту же проблему решает и контекст в Реакте. Мы создаём контекст, через провайдер указываем, что именно нужно хранить и передавать, а через консьюмер — в каком компоненте эти значения забирать и использовать:
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.time | performance.measure |
---|---|---|
Redux | 12 мс | 13 мс |
Context + hooks | 9 мс | 8 мс |
Однако, стоит сделать оговорку, что приложение «маленькое и глупое». В приложениях с большим стейтом, вероятно, могут быть проблемы с производительностью. В частности может понадобиться система селекторов данных из стейта, чтобы перерендер происходил только в тех компонентах, где это действительно нужно.
Минусы
Вызов экшенов стал чуть более многословным из-за прямого использования dispatch
. И если работать с контекстом без useContext
, придётся использовать паттерн render-prop, из-за чего может подняться щит-сторм ¯\_(ツ)_/¯
Про вероятную необходимость системы селекторов я уже говорил выше. Без неё приложение может полностью перерисовываться на каждый чих, что сведёт на нет весь смысл подхода Реакта к построению интерфейсов.
Но если серьёзно, хуки — пока что в стадии RFC, и возможно многое поменяется. Поэтому переписывать свои приложения на них не советует даже Дэн Абрамов. То есть это экспериментальная фигня.
Хотя выглядит всё равно заманчиво 🙃