Встроенная альтернатива Redux с React Context и хуками

в 4:39, , рубрики: Context API, ReactJS, redux

От переводчика:

Представляю вольный перевод статьи о том, как реализовать эффективное решение для замены Redux контекстом React и хуками. Указание на ошибки в переводе или тексте приветствуются. Приятного просмотра.


С момента выхода нового Context API в React 16.3.0 многие люди задавали себе вопрос, достаточно ли хорош новый API, чтоб рассматривать его как замену Redux? Я думал о том же, но до конца не понимал даже после выхода версии 16.8.0 с хуками. Я стараюсь пользоваться популярными технологиями, не всегда понимая всего спектра проблем, которые они решают, так что я слишком сильно привык к Redux.

И вот так получилось, что я подписался на новостную рассылку от Кента Си Доддс (Kent C. Dodds’) и обнаружил несколько email на тему контекста и управлением состоянием. Я начал читать…. и читать… и спустя 5 блог постов что-то щелкнуло.

Чтобы понять все основные концепты стоящие за этим, мы сделаем кнопку, по клику на которую мы будем получить анекдоты с icanhazdadjoke и отображать их. Это небольшой, но достаточный пример.

Для подготовки, давайте начнем с двух, казалось бы, случайных советов.

Во-первых, позвольте представить моего друга console.count:

console.count('Button')
// Button: 1
console.count('Button')
// Button: 2
console.count('App')
// App: 1
console.count('Button')
// Button: 3

Мы добавим вызов console.count в каждый компонент, чтобы посмотреть сколько раз он ре-рендерится. Довольно прикольно, да?

Во-вторых, когда React компонент ре-рендерится, он не ре-рендерит контент, переданный как children.

function Parent({ children }) {
  const [count, setCount] = React.useState()
  console.count('Parent')
  return (
    <div>
      <button type="button" onClick={() => {
        setCount(count => count + 1)
      }}>
        Force re-render
      </button>
      {children}
    </div>
  )
}

function Child() {
  console.count('Child')
  return <div />
}

function App() {
  return (
    <Parent>
      <Child />
    </Parent>
  )
}

После нескольких кликов по кнопке, вы должны увидеть следующее содержимое в консоли:

Parent: 1
Child: 1
Parent: 2
Parent: 3
Parent: 4

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

Теперь, когда мы готовы, давайте создадим скелет нашего приложения:

import React from 'react'

function Button() {
  console.count('Button')
  return (
    <button type="button">
      Fetch dad joke
    </button>
  )
}

function DadJoke() {
  console.count('DadJoke')
  return (
    <p>Fetched dad joke</p>
  )
}

function App() {
  console.count('App')
  return (
    <div>
      <Button />
      <DadJoke />
    </div>
  )
}

export default App

Button должна получить генератор действия (прим. Action Creator. Перевод взят из документации Redux на русском языке) который будет получать анекдот. DadJoke должен получить состояние, и App отобразить оба компонента используя контекст Provider.

Теперь создадим пользовательский компонент и назовем его DadJokeProvider, который внутри себя будет управлять состоянием и оборачивать дочерние компоненты в Context Provider. Помните, что обновление его состояния не будет ре-рендерить все приложение благодаря упомянутой выше оптимизации children в React.

Итак, создадим файл и назовем его contexts/dad-joke.js:

import React from 'react'

const DadJokeContext = React.createContext()

export function DadJokeContextProvider({ children }) {
  const state = { dadJoke: null }
  const actions = {
    fetchDadJoke: () => {},
  }
  return (
    <DadJokeContext.Provider value={{ state, actions }}>
      {children}
    </DadJokeContext.Provider>
  )
}

Так же экспортируем 2 хука для получения значения из контекста.

export function useDadJokeState() {
  return React.useContext(DadJokeContext).state
}

export function useDadJokeActions() {
  return React.useContext(DadJokeContext).actions
}

Теперь мы уже можем реализовать это:

import React from 'react'
import {
  DadJokeProvider,
  useDadJokeState,
  useDadJokeActions,
} from './contexts/dad-joke'

function Button() {
  const { fetchDadJoke } = useDadJokeActions()
  console.count('Button')
  return (
    <button type="button" onClick={fetchDadJoke}>
      Fetch dad joke
    </button>
  )
}

function DadJoke() {
  const { dadJoke } = useDadJokeState()
  console.count('DadJoke')
  return (
    <p>{dadJoke}</p>
  )
}

function App() {
  console.count('App')
  return (
    <DadJokeProvider>
      <Button />
      <DadJoke />
    </DadJokeProvider>
  )
}

export default App

Вот! Спасибо API, который мы сделали, используя хуки. Мы больше не будем делать никаких изменений в этом файле на протяжении всего поста.

Начнем добавлять функционал в наш файл с контекстом, начиная с состояния DadJokeProvider. Да, мы могли бы просто использовать хук useState, но давайте вместо этого управлять нашим состоянием через reducer, просто добавив хорошо известный и любимый нами функционал Redux.

function reducer(state, action) {
  switch (action.type) {
    case 'SET_DAD_JOKE':
      return {
        ...state,
        dadJoke: action.payload,
      }
    default:
      return new Error();
  }
}

Теперь мы можем передать этот reducer в хук useReducer и получить анекдоты с API:


export function DadJokeProvider({ children }) {
  const [state, dispatch] = React.useReducer(reducer, { dadJoke: null })

  async function fetchDadJoke() {
    const response = await fetch('https://icanhazdadjoke.com', {
      headers: {
        accept: 'application/json',
      },
    })
    const data = await response.json()
    dispatch({
      type: 'SET_DAD_JOKE',
      payload: data.joke,
    })
  }

  const actions = {
    fetchDadJoke,
  }

  return (
    <DadJokeContext.Provider value={{ state, actions }}>
      {children}
    </DadJokeContext.Provider>
  )
}

Должно работать! Клик по кнопке должен получить и отображать шутки!

Давайте проверим консоль:

App: 1
Button: 1
DadJoke: 1
Button: 2
DadJoke: 2
Button: 3
DadJoke: 3

Оба компонента ре-рендерятся каждый раз, когда обновляется состояние, но только один из них реально использует его. Представьте себе реальное приложение, в котором сотни компонентов используют только действия. Было бы неплохо, если бы мы могли предоставить все эти необязательные ре-рендеры?

И тут мы вступаем на территорию относительного равенства, поэтому небольшое напоминание:

const obj = {}
// ссылка равна ссылке на саму себя console.log(obj === obj) // true

// новый объект не равен другому новому объекту
// Это 2 разный объекта
console.log({} === {}) // false

Компонент, использующий контекст, будет ре-рендериться каждый раз, когда значение этого контекста изменяется. Давайте рассмотрим значение нашего Context Provider:

<DadJokeContext.Provider value={{ state, actions }}>

Здесь мы создаем новый объект во время каждого ре-рендера, но это неизбежно, потому что новый объект будет создаваться каждый раз, когда мы будем выполнять действие (dispatch), поэтому просто невозможно закешировать (memoize) это значение.

И все это выглядит как конец истории, да?

Если посмотрим функцию fetchDadJoke, единственное, что она использует из внешней области видимости это dispatch, правильно? В общем, я собираюсь открыть вам небольшой секрет о функциях, созданных в useReducer и useState. Для краткости я буду использовать useState в качестве примера:


let prevSetCount

function Counter() {
  const [count, setCount] = React.useState()
  if (typeof prevSetCount !== 'undefined') {
    console.log(setCount === prevSetCount)
  }
  prevSetCount = setCount
  return (
    <button type="button" onClick={() => {
      setCount(count => count + 1)
    }}>
      Increment
    </button>
  )
}

Нажмите на кнопку несколько раз и посмотрите в консоль:

true
true
true

Вы заметите, что setCount одна та же функция для каждого рендера. Это так же применимо и для нашей dispatch функции.

Это означает, что наша функция fetchDadJoke не зависит от чего-либо, что меняется со временем, и не зависит ни от каких других генераторов действий, поэтому объект действий нужно создавать только один раз, при первом рендере:

const actions = React.useMemo(() => ({
    fetchDadJoke,
  }), [])

Теперь, когда у нас есть закешированный объект с действиями, можем ли мы оптимизировать значение контекста? Вообще, нет, потому что неважно как хорошо мы оптимизируем объект значений, нам все равно нужно каждый раз создавать новый из-за изменений состояния. Однако, что если мы вынесем объект действий из существующего контекст в новый? Кто сказал, что у нас может быть лишь один контекст?

const DadJokeStateContext = React.createContext()
const DadJokeActionsContext = React.createContext()

Мы можем объединить оба контекста в нашем DadJokeProvider:

 return (
    <DadJokeStateContext.Provider value={state}>
      <DadJokeActionsContext.Provider value={actions}>
        {children}
      </DadJokeActionsContext.Provider>
    </DadJokeStateContext.Provider>
  )

И подправить наши хуки:

export function useDadJokeState() {
  return React.useContext(DadJokeStateContext)
}

export function useDadJokeActions() {
  return React.useContext(DadJokeActionsContext)
}

И мы закончили! Серьезно, загрузите столько анекдотов, сколько хотите и убедитесь в этом сами.

App: 1
Button: 1
DadJoke: 1
DadJoke: 2
DadJoke: 3
DadJoke: 4
DadJoke: 5

Вот вы и реализовали свое собственное оптимизированное решение для управления состоянием! Вы можете создавать различные провайдеры, используя этот двухконтекстный шаблон для создания своего приложения, но и это еще не все, вы также можете рендерить один и тот же компонент провайдера несколько раз! Чтооо?! Да, попробуйте, рендер DadJokeProvider в нескольких местах и смотрите, как ваша реализация управления состоянием легко масштабируется!

Дайте волю вашему воображению и пересмотрите, зачем вам действительно нужен Redux.

Спасибо Кенту Си Доддс (Kent C. Dodds) за статьи о двухконтекстном шаблоне. Я нигде больше не видел его и мне кажется это меняет правила игры.

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

Когда использовать useMemo и useCallback
Как оптимизировать значение контекста
Как эффективно использовать React Context
Управление состояним приложения в React.
Один простой трюк для оптимизации ре-рендеров в React

Автор: Иван Калинин

Источник


* - обязательные к заполнению поля