
От Редакса к хукам
В Реакте в версии 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 = () => {
<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) => {
switch(action.type) {
// ...
}
}
// 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, и возможно многое поменяется. Поэтому переписывать свои приложения на них не советует даже Дэн Абрамов. То есть это экспериментальная фигня.
Хотя выглядит всё равно заманчиво
Ссылки
Документация
Доклады
Статьи со сравнениями
Измерения перформанса
Исходники и примеры