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

В Реакте в версии 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.timeperformance.measure
Redux12 мс13 мс
Context + hooks9 мс8 мс

Минусы

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

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

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

Ссылки

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

Доклады

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

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

Исходники и примеры

← Лень и поиск предназначения. Андрей Курпатов Рим, октябрь 2018 →