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

В Реакте в версии 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, carlrippon.com
Иллюстрация проблемы 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.time performance.measure
Redux 12 мс 13 мс
Context + hooks 9 мс 8 мс

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

Минусы

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

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

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

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

Ссылки

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

Доклады

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

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