Как эффективно применять React Context

Автор оригинала: Kent C. Dodds
  • Перевод

В статье Как управлять состоянием 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. Вы можете (и скорее всего должны) иметь несколько логически не связанных контекстов в вашем приложении.

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 7

    +2

    В который раз убеждаюсь, что Контексты с Хуками в сыром виде совершенно не пригодны для работы. Для этого нужно написать свой мини-фреймворк (как у вас), задать границы применения, добавить щепотку мемоизации… И только тогда можно пользоваться этим без боли.


    Вопрос, только, а зачем городить свой велосипед? (Вопрос не к автору статьи, а, скорее, риторический).


    Можно взять Recoil, Mobx, ну или Redux-tookit на худой конец.

      0
      Реакт в сыром виде не пригоден для работы, придется написать свой мини-фреймворк. Зачем городить велосипед, можно взять angular/ember ну или backbone на худой конец.
        0
        backbone

        А чем Backbone вам поможет? Это же ещё больший конструктор.

          0
          Реакт нинужен.
          –1
          Пошёл на фейсбук посмотреть используют ли они контекст. Используют во-всю. В статье приведены примитивные примеры, для них не нужно разделения на отдельные контексты, потому что «React — быстр». Вполне нормально, что что-то там «перерендрится», до тех пор пока не возникают «тормоза». Мы делали «бесконечную древовидную таблицу» с раскрывашками, возможностью отметить строки, да так чтобы отмеченная родительская строка отмечала всё дочерние и бесконечной подгрузкой дочерних данных, там контекст не подходит потому, что там вообще ничего не подходит. Не нужно делать такие компоненты, хоть и очень хочется. Даже если пихать всё в react-window получается, что нужно пересчитывать дерево (бесконечное) на каждый чих и это большая печаль. А без react-window всё страшно тормозит с любым стейт-менеджером.
            0

            Virtual-scroll компоненты (если делать их по уму, а не как в $mol) — это всегда большое количество подводных камней, костылей и прочего. Особенно когда содержимое это дерево произвольного размера элементов. Особенно когда часть из них могут иметь height на десятки тысяч пикселей. Особенно когда нужно чтобы это всё было редактируемым. Ах да, ещё же поиск :)

          +3

          Пожалуй стоит упомянуть ещё про пару моментов, которые можно было бы назвать западнёй:


          1) Не пишите в коде вещи вроде


          <context.Provider value={{ dispatch, state }}/>

          Причина: { dispatch, state } — это всегда новый объект. А там внутри что-то вроде:


          if (oldValue !== prevValue) notifyConsumers();

          Это в свою очередь reset-ит context value при каждом рендере компонента, который использует Provider. А это в свою очередь принудительно rerender-ит всех тех кто использует useState.


          Отсюда вывод — нужно группировать несколько значений в одно каждый render? useMemo к вашим услугам


          В вашем коде я такого не заметил, но зато вижу такое регулярно в статьях про контекст. Думаю не лишним будет ещё раз про это упомянуть.


          3) При таком вот подходе нельзя подписаться только под изменение какого-то конкретного поля в state, т.к. любое изменение state вызывает rerender всех потребителей контекста. Всегда без исключения. И кастомный хук не поможет, т.к. React пока не умеет в хуки оторванные от render-а.


          Это причина того, почему тот же самый redux не использует context api для хранения state. Вместо этого там своя модель подписки. Рекомендую посмотреть как устроен useSelector. В свою очередь это создаёт проблемы при использовании redux с какими-нибудь другими штуками вроде react-router-а, который как раз использует контекст для таких вещей. Они обновляются столь не синхронизировано, что бывают очень бесячие баги из области race condition.


          По сути context api пока выглядит очень куцым, недоделанным. Оно слишком топорное и с его помощью невозможно реализовать хоть сколько нибудь сложные вещи. Но кажется там у них идёт работа над его улучшением.

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

          Самое читаемое