В статье Как управлять состоянием React приложения без сторонних библиотек, я писал о том как сочетание локального состояния и контекста (React Context) поможет вам упростить управление состоянием при разработке приложения. В этой статье я продолжу эту тему - мы поговорим о методах эффективного использования потребителей контекста (Context Consumer), которые помогут вам избежать ошибок и упростят разработку приложений и/или библиотек.

Рекомендую перед прочтением этой статьи прочитать Как управлять состоянием React приложения без сторонних библиотек. Особенно обратите внимание на рекомендацию о том что не нужно применять контексты для решения всех проблем связанных с передачей состояния. Однако, когда вам все таки понадобиться применять контексты, надеюсь эта статья поможет вам делать это наиболее эффективным образом. Также помните о том что контексты НЕ обязательно должны быть глобальными, контекст может (и скорее всего, должен) быть частью какой либо ветки в структуре вашего приложения.

Давайте, для начала, создадим файл src/count-context.js и пропишем там наш контекст:

// src/count-context.js
import React from 'react'

const CountStateContext = React.createContext()
const CountDispatchContext = React.createContext()

Во-первых, в CountStateContext нет начального значения. Его можно было бы прописать, например, так: React.createContext ({count: 0}), но в этом нет смысла. Дефолтное значение defaultValue будет полезно только в такой ситуации:

function CountDisplay() {
  const {count} = React.useContext(CountStateContext) // <-
  return <div>{count}</div>
}

ReactDOM.render(<CountDisplay />, document.getElementById('??'))

Из-за того что у CountStateContext нет значения по умолчанию, мы получим ошибку в useContext Это из-за того что наше дефолтное значение не было определено, оно undefined а мы не можем передавать undefined в useContext.

Никому не нравятся runtime-ошибки, так что, скорее всего, вашей первой реакцией будет добавление какого-то дефолтного значения. Но зачем вообще использовать контекст если вы не используете его для какого-то реального значения? Если он будет использовать значение по умолчанию, то в нем едва ли есть какой либо смысл. В подавляющем числе случаев когда вы создаете и используете контекст в своем приложении вы хотите чтобы потребители (Context Consumer), которые используют useContext, отображались внутри провайдера (Context Provider), который может передать какое-то полезное значение.

Замечу, что да, возможны ситуации когда значения по умолчанию полезны, но в большинстве случаев они не нужны.

Документация Реакта говорит о том что передача дефолтных значений "может быть полезной для тестирования компонентов в изоляции без необходимости оборачивать их". Да, так делать можно, но, на мой взгляд, лучше оборачивать компоненты нужным контекстом. Помните - вы не можете быть уверены в тесте на сто процентов если делаете в нем что-то, чего не делаете в самом приложении. Существуют ситуации когда нужно так делать The Merits of Mocking, но это не тот случай.

Примечание. Если вы используете Flow или TypeScript, и применяете React.useContext, отсутствие дефолтного значения может сильно раздражать. Ниже я покажу как избежать этой проблемы.

Так зачем нужен этот CountDispatchContext? Уже какое-то время я экспериментирую с контекстами, я так же общаюсь со знакомыми из Facebook которые экспериментируют с ними намного дольше чем я, и могу сказать что самая простая вещь которую вы можете сделать для того чтобы избежать проблем с контекстами (особенно если вы вызываете dispatch в эффектах) это разделить состояние (state) и dispatch в контексте. Это звучит странно, но сейчас я все объясню!

Кастомный компонент провайдер

Чтобы модуль контекста работал, нам нужно использовать Provider и компонент, который передает значение. Пример:

function App() {
  return (
    <CountProvider>
      <CountDisplay />
      <Counter />
    </CountProvider>
  )
}

ReactDOM.render(<App />, document.getElementById('??'))

Итак, давайте напишем код этого компонента:

// src/count-context.js
import React from 'react'

const CountStateContext = React.createContext()
const CountDispatchContext = React.createContext()

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    case 'decrement': {
      return {count: state.count - 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  return (
    <CountStateContext.Provider value={state}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  )
}

export {CountProvider}

Примечание. Это довольно надуманный пример. Я намеренно переусложнил решение чтобы показать более приближенный к реальности код. Это не значит что всегда нужно так делать. Применяйте хук useState если это больше подходит для вашей ситуации. На практике, какие-то ваши провайдеры будут простыми и короткими, как этот, а другие будут КУДА более сложными, и будут применять множество различных хуков.

Кастомный хук потребитель (Consumer Hook)

Обычно разработчики используют контексты таким образом:

import React from 'react'
import {SomethingContext} from 'some-context-package'

function YourComponent() {
  const something = React.useContext(SomethingContext)
}

Но на мой взгляд существует куда более удобный способ:

import React from 'react'
import {useSomething} from 'some-context-package'

function YourComponent() {
  const something = useSomething()
}

Чем таком подход лучше? Ну, это открывает нам целый ряд новых возможностей:

// src/count-context.js
import React from 'react'

const CountStateContext = React.createContext()
const CountDispatchContext = React.createContext()

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    case 'decrement': {
      return {count: state.count - 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  return (
    <CountStateContext.Provider value={state}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  )
}

// наши кастомные хуки:

function useCountState() {
  const context = React.useContext(CountStateContext)
  if (context === undefined) {
    throw new Error('useCountState must be used within a CountProvider')
  }
  return context
}

function useCountDispatch() {
  const context = React.useContext(CountDispatchContext)
  if (context === undefined) {
    throw new Error('useCountDispatch must be used within a CountProvider')
  }
  return context
}

export {CountProvider, useCountState, useCountDispatch}

Во-первых, кастомные хуки useCountState и useCountDispatch используют React.useContext для того чтобы получить значение контекста из ближайшего CountProvider. В ситуации когда значения нет, мы показываем сообщение об ошибке, которое указывает на то что хук должен быть использован внутри CountProvider. Так как это ошибка, то, конечно, полезно эту ошибку отобразить.

Кастомный компонент потребитель

Если у вас есть возможность использовать хуки, то вы можете пропустить этот раздел. Однако, если вы используете React < 16.8.0, или, если вам нужно использовать Контекст в классовом компоненте, вот каким образом вы можете поступить, используя подход render-prop:

function CountConsumer({children}) {
  return (
    <CountStateContext.Consumer>
      {context => {
        if (context === undefined) {
          throw new Error('CountConsumer must be used within a CountProvider')
        }
        return children(context)
      }}
    </CountStateContext.Consumer>
  )
}

Именно так я и делал до того как появились хуки. Это не плохой подход. Однако, если вы можете использовать хуки, используйте хуки.

TypeScript / Flow

Выше я обещал рассказать о том как избежать ошибок связанных с тем что мы не стали указывать дефолтное значение (defaultValue) при использовании TypeScript или Flow. Вот оно:

// src/count-context.tsx
import * as React from 'react'

type Action = {type: 'increment'} | {type: 'decrement'}
type Dispatch = (action: Action) => void
type State = {count: number}
type CountProviderProps = {children: React.ReactNode}

const CountStateContext = React.createContext<State | undefined>(undefined)
const CountDispatchContext = React.createContext<Dispatch | undefined>(
  undefined,
)

function countReducer(state: State, action: Action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    case 'decrement': {
      return {count: state.count - 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}: CountProviderProps) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  return (
    <CountStateContext.Provider value={state}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  )
}

function useCountState() {
  const context = React.useContext(CountStateContext)
  if (context === undefined) {
    throw new Error('useCountState must be used within a CountProvider')
  }
  return context
}

function useCountDispatch() {
  const context = React.useContext(CountDispatchContext)
  if (context === undefined) {
    throw new Error('useCountDispatch must be used within a CountProvider')
  }
  return context
}

export {CountProvider, useCountState, useCountDispatch}

При таком подходе можно использовать useCountState или useCountDispatch без проверки на undefined, так мы уже провели эту проверку!

Вот версия на codesandbox

Что на счет type типов в dispatch?

В этот момент пользователи Редакса могли бы закричать: "Эй, а где генераторы экшенов?!" (Action Creator). Если вам хочется, то вы можете применять генераторы экшенов, в этом нет ничего плохого. Но на мой взгляд это лишняя абстракция, в которой нет необходимости. К тому же, если вы используете TypeScript или Flow, и вы тщательно прописали типы для ваших экшенов, то вам не нужно прописывать генераторы экшенов. Вы уже и так получаете автодополнение и отображение ошибок типов в редакторе кода:

Мне действительно нравиться передавать dispatch таким образом, и, как дополнительный плюс, dispatch стабилен все время пока живет компонент который создал его, так что его можно смело добавлять в лист зависимостей useEffect (не имеет значения добавлен он туда или нет).

Если вы не типизируете свой JavaScript код (вообще, неплохо было бы типизировать), то ошибка которую мы отображаем при пропущенном типе экшена - отказоустойчива.

Что на счет асинхронности?

Это хороший вопрос. Что делать если нужно сделать асинхронный запрос, и вам нужно отправлять (dispatch) данные в ходе этого запроса? Да, это можно сделать в самом вызываемом компоненте, но получается придется прописывать это все для каждого компонента.

Я предлагаю сделать вспомогательную функцию внутри вашего контекстного модуля, эта функция будет принимать dispatch вместе с любыми другими данными которые вам нужны. Вот пример такой функции (из моего курса Advanced React Patterns Workshop):

// user-context.js
async function updateUser(dispatch, user, updates) {
  dispatch({type: 'start update', updates})
  try {
    const updatedUser = await userClient.updateUser(user, updates)
    dispatch({type: 'finish update', updatedUser})
  } catch (error) {
    dispatch({type: 'fail update', error})
  }
}

export {UserProvider, useUserDispatch, useUserState, updateUser}

После чего вы можете использовать ее так:

// user-profile.js
import {useUserState, useUserDispatch, updateUser} from './user-context'

function UserSettings() {
  const {user, status, error} = useUserState()
  const userDispatch = useUserDispatch()
  function handleSubmit(event) {
    event.preventDefault()
    updateUser(userDispatch, user, formState)
  }

  // more code...
}

Совмещение состояния (state) и отправки (dispatch)

Некоторые считают такой код излишним:

const state = useCountState()
const dispatch = useCountDispatch()

Они спрашивают, "можно ли просто делать так?":

const [state, dispatch] = useCount()

Да, можно:

function useCount() {
  return [useCountState(), useCountDispatch()]
}

Итоги

Финальная версия кода:

// src/count-context.js
import React from 'react'

const CountStateContext = React.createContext()
const CountDispatchContext = React.createContext()

function countReducer(state, action) {
  switch (action.type) {
    case 'increment': {
      return {count: state.count + 1}
    }
    case 'decrement': {
      return {count: state.count - 1}
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function CountProvider({children}) {
  const [state, dispatch] = React.useReducer(countReducer, {count: 0})
  return (
    <CountStateContext.Provider value={state}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  )
}

function useCountState() {
  const context = React.useContext(CountStateContext)
  if (context === undefined) {
    throw new Error('useCountState must be used within a CountProvider')
  }
  return context
}

function useCountDispatch() {
  const context = React.useContext(CountDispatchContext)
  if (context === undefined) {
    throw new Error('useCountDispatch must be used within a CountProvider')
  }
  return context
}

export {CountProvider, useCountState, useCountDispatch}

Вот код на codesandbox

Заметьте что я НЕ экспортирую CountContext. Это сделано намеренно. Благодаря этому существует только один способ предоставлять значение контекста и только один способ потреблять его. Это позволяет убедиться в том что разработчики используют значение так как это было задумано, это также позволяет добавлять полезные утилиты для потребителей.

Надеюсь это было полезно для вас! Помните:

  1. Не нужно сразу же хвататься за контексты при решении любой проблемы связанной с передачей состояний.

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

  3. Вы можете (и скорее всего должны) иметь несколько логически не связанных контекстов в вашем приложении.