Почему React Context не является "State management" инструментом (и почему он не может заменить Redux)
Arif Balaev
Posted on January 25, 2021
Вольный перевод статьи от Марка Эриксона.
Вступление
“Context против Redux” - одна из самых обсуждаемых тем в React сообществе с момента релиза текущего API React Context. К сожалению, большая часть этих «дебатов» происходит из-за путаницы по поводу назначения и вариантов использования этих двух инструментов. Я сотни раз отвечал на различные вопросы в интернете о Context и Redux (включая мои посты Redux - Not Dead Yet!, React, Redux and Context Behavior, A (Mostly) Complete Guide to React Rendering Behavior и When (and when not) to Reach for Redux), но путаница продолжает ухудшаться.
Учитывая преобладание вопросов по этой теме, я формулирую этот пост как окончательный ответ на данные вопросы. Я попытаюсь прояснить, что на самом деле такое Context и Redux, как они предназначены для использования, чем они отличаются и когда вам следует их использовать.
Если кратко
Context и Redux это одно то же?
Нет. Это разные инструменты, которые делают разные вещи, и вы используете их для разных целей.
Context это “State management” инструмент?
Нет. Context - это форма Dependency Injection. Он является транспортным механизм, который ничем не «управляет». Любой «state management» создается вами и вашим собственным кодом, обычно через useState/useReducer
.
Context вместе с useReducer заменяют Redux?
Нет. У них есть некоторые сходства и совпадения, но есть существенные различия в их возможностях.
Когда мне следует использовать Context?
Каждый раз, когда у вас есть какое-то значение, которое вы хотите сделать доступным для части вашего дерева React компонентов, без передачи этого значения в качестве проп (props) через каждый уровень компонентов.
Когда мне стоит использовать Context вместе с useReducer?
Когда у вас есть умеренно сложные потребности в управлении состоянием React компонентов в определенном разделе вашего приложения.
Когда мне стоит использовать Redux вместо этого?
Redux наиболее полезен в случаях, когда:
- У вас есть большее количество состояний приложения, которое необходимо во многих местах приложения.
- Состояние приложения часто обновляется с течением времени
- Логика обновления этого состояния может быть сложной.
- Приложение имеет кодовую базу среднего или большого размера, и над ним могут работать много людей.
- Вы хотите понимать, когда, почему и как обновлялось состояние вашего приложения, а также визуализировать изменения вашего состояния с течением времени.
- Вам нужны более мощные возможности для управления side эффектов, устойчивости и сериализации данных.
Понимание Context и Redux
Чтобы правильно использовать любой инструмент, важно понимать:
- В чем его суть
- Какую проблему он пытается решить
- Когда и почему изначально он был создан
Также важно понимать, какие проблемы вы пытаетесь решить в своем собственном приложении прямо сейчас, и выбирать инструменты, которые решают вашу проблему лучше всего - не потому, что кто-то сказал, что вы должны их использовать, не потому, что они популярны, а потому, что это то, что лучше всего подходит вам в данной конкретной ситуации.
Большая часть путаницы по поводу «противостояния Context и Redux» происходит из-за отсутствия понимания того, что на самом деле делают эти инструменты и какие проблемы они решают. Итак, чтобы действительно знать, когда их использовать, нам нужно сначала четко определить, что они делают и какие проблемы решают.
Что такое React Context?
Начнем с просмотра Context описания из оригинальной React документации:
Context предоставляет способ пробрасывать данные через дерево компонентов без необходимости передавать пропы (props) вручную на каждом уровне.
В типичном React приложении данные передаются сверху вниз (от родительского к дочернему) через пропы (props), но это может оказаться избыточным для определенных типов проп (например, локализации, UI темы), которые требуются для многих компонентов в приложении. Контекст предоставляет способ делиться подобными значениями между компонентами без необходимости явно передавать пропы через каждый уровень дерева.
Обратите внимание, что здесь ничего не говорится об «управлении» значениями - только ссылаются на «пробрасывание» и «способ делиться» значениями.
Текущий API React Context (React.createContext()) впервые был выпущен в React 16.3. Он заменил устаревший Context API, который был доступен с ранних версиях React, но имел серьезные недостатки в проектировании. Основная проблема с устаревшим контекстом заключалась в том, что обновления значений, передаваемых через контекст, могли быть «заблокированы», если компонент пропустил рендеринг через shouldComponentUpdate
. Поскольку многие компоненты полагались на shouldComponentUpdate
для оптимизации производительности, это делало устаревший контекст бесполезным для передачи простых данных. createContext()
был разработан для решения этой проблемы, поэтому любое обновление значения будет видно в дочерних компонентах, даже если компонент в середине пропускает рендеринг.
Использование Context
Чтобы использовать React Context в приложении нужно сделать следующие шаги:
- Сначала вызвать
const MyContext = React.createContext()
, чтобы создать экземпляр объекта контекста - В родительском компоненте в методе render написать
MyContext.Provider value={someValue}
. Это помещает некоторую часть данных в контекст. Данное значение может быть чем угодно - строкой, числом, объектом, массивом, экземпляром класса, эмиттером событий и т.д. - Затем в любом компоненте, вложенном в этот provider, вызываем
const theContextValue = useContext(MyContext)
.
Каждый раз, когда родительский компонент ререндерится и передает новую ссылку для context provider как значение value
, то любой компонент, который читает из этого контекста, будет вынужден перерендериться.
Чаще всего value контекста - это то, что приходит из состояния React компонента, например:
function ParentComponent() {
const [counter, setCounter] = useState(0);
// создается объект, содержащий и значение и сеттер
const contextValue = {counter, setCounter};
return (
<MyContext.Provider value={contextValue}>
<SomeChildComponent />
</MyContext.Provider>
)
}
Затем дочерний компонент может вызвать useContext
и прочитать значение:
function NestedChildComponent() {
const { counter, setCounter } = useContext(MyContext);
// сделай что-нибудь со значением и сеттером counter
}
Назначение и варианты использования Context
Исходя из этого, мы можем видеть, что Context вообще ничем не «управляет». Вместо этого это похоже на трубу или червоточину. Вы помещаете что-то в верхний конец канала с помощью <MyContext.Provider>
, и оно (что бы это ни было) проходит по каналу, пока не выскочит на другой конец, где другой компонент запрашивает его с помощью useContext(MyProvider)
.
Итак, основная цель использования Context - избежать «prop-drilling». Вместо того, чтобы передавать это значение в качестве пропы, явно через каждый уровень дерева компонентов, который в нем нуждается, любой компонент, вложенный в <MyContext.Provider>
, может просто сказать useContext(MyContext)
, чтобы получить значение по мере необходимости. Это действительно упрощает код, потому что нам не нужно писать лишнюю логику пробрасывая проп.
Концептуально это форма «Dependency Injection». Мы знаем, что дочернему компоненту требуется значение определенного типа, но он не пытается самостоятельно создать это значение. Вместо этого предполагается, что какой-то родительский компонент пробросит это значение во время выполнения.
Что такое Redux?
Для сравнения давайте посмотрим на описание в документации из the "Redux Essentials" туториала в оригинальных доках:
Redux - это паттерн и библиотека для управления и обновления состояния приложения с использованием событий, называемых «экшенами». Он обеспечивает централизованное хранилище состояния, которое необходимо использовать во всем приложении, с правилами, гарантирующими, что состояние может обновляться только предсказуемым образом.
Redux помогает вам управлять “глобальным” состоянием - состояние, которое необходимо во многих частях вашего приложения.
Паттерны и инструменты, предоставляемые Redux, упрощают понимание того, когда, где, почему и как обновляется состояние в вашем приложении, и как логика вашего приложения будет вести себя, когда эти изменения произойдут.
Обратите внимание, что это описание:
- конкретно относится к "управлению состоянием”
- говорит, что цель Redux - помочь вам понять, как состояние меняется с течением времени
Исторически Redux создавался как реализация «архитектуры Flux», которая была паттерном, впервые предложенным Facebook в 2014 году, через год после выхода React. После этого объявления сообщество создало десятки вдохновленных от Flux библиотек с различными подходами к концепциям Flux. Redux вышел в 2015 году и быстро выиграл «Flux войну», потому что у него была лучшая концепция, он соответствовал задачам, которые люди пытались решить, и отлично работал с React.
В архитектурном плане Redux подчеркивает использование принципов функционального программирования, чтобы помочь вам написать как можно больше кода в виде предсказуемых функций-редьюсеров, и отделяет идею «какое событие произошло» от логики, которая определяет, «как состояние обновляется, когда это событие происходит". Redux также использует middleware как способ расширения возможностей Redux хранилища, включая обработку side эффектов.
Redux также имеет Redux Devtools, которые позволяют вам видеть историю действий и изменений состояния вашего приложения с течением времени.
Redux и React
Сам Redux не зависит от пользовательского интерфейса - вы можете использовать его с любым уровнем пользовательского интерфейса (React, Vue, Angular, vanilla JS и т.д.) или вообще без какого-либо пользовательского интерфейса.
Тем не менее, Redux чаще всего используется с React. Библиотека React-Redux - это официальный уровень связи с пользовательским интерфейсом, который позволяет компонентам React взаимодействовать с Redux хранилищем, считывая значения из состояния Redux и отправляя экшены. Итак, когда большинство людей ссылаются на «Redux», они на самом деле имеют в виду «совместное использование Redux хранилища и библиотеки React-Redux».
React-Redux позволяет любому компоненту React в приложении взаимодействовать с хранилищем Redux. Это возможно только потому, что React-Redux внутри использует Context. Однако важно отметить, что React-Redux передает только экземпляр Redux хранилища через контекст, а не текущее значение состояния!. Фактически это пример использования Context в качестве dependency injection, как упоминалось выше. Мы знаем, что наши React компоненты, подключенные к Redux, должны взаимодействовать с Redux хранилищем, но мы не знаем и не заботимся о том, какое это Redux хранилище, когда мы определяем компонент. Фактическое хранилище Redux инжектится в дерево в рантайме с помощью React-Redux <Provider>
компонента.
Из-за этого React-Redux также можно использовать, чтобы избежать prop-drilling, особенно потому, что React-Redux использует Context внутри себя. Вместо того, чтобы явно помещать новое значение в <MyContext.Provider>
самостоятельно, вы можете поместить эти данные в хранилище Redux, а затем получить к ним доступ в любом месте.
Назначения и варианты использования (React-)Redux
Основная причина использования Redux зафиксирована в описании из документации Redux:
Паттерны и инструменты, предоставляемые Redux’ом, упрощают понимание того, когда, где, почему и как обновляется состояние в вашем приложении, и как логика вашего приложения будет вести себя, когда эти изменения произойдут.
Есть дополнительные причины, по которым вы можете захотеть использовать Redux. «Избегать prop-drilling» - одна из тех причин. Многие люди выбрали Redux на раннем этапе специально, чтобы позволить избежать prop-drilling, так как устаревший React контекст был сломанным а React-Redux работал верно.
Другие веские причины использовать Redux включают:
- Желание написать логику управления состоянием полностью отдельно от уровня пользовательского интерфейса
- Совместное использование логики управления состоянием между различными уровнями пользовательского интерфейса (например, приложением, которое переносится с AngularJS на React)
- Использование мощи Redux middleware для добавления дополнительной логики при отправке действий
- Возможность сохранять части Redux состояния
- Включение отчетов об ошибках, которые могут воспроизводиться разработчиками
- Более быстрая отладка логики и пользовательского интерфейса в процессе разработки
Дэн Абрамов перечислил ряд таких вариантов использования, когда писал свой пост You Might Not Need Redux еще в 2016 году.
Почему Context это не “State Management”?
«Состояние» - это любые данные, описывающие поведение приложения. Мы могли бы разделить его на категории, такие как «серверное состояние», «состояние коммуникаций» и «состояние местоположения», если захотим, но ключевым моментом является то, что данные сохраняются, читаются, обновляются и используются.
David Khourshid, автор библиотеки XState и эксперт по state machines, сказал:
«State management - это то, как состояние меняется с течением времени».
Исходя из этого, мы можем сказать, что «state management» означает наличие возможности:
- сохранить начальное значение
- прочитать текущее значение
- обновить значение
Также обычно существует способ получить уведомление об изменении текущего значения.
Хуки useState
и useReducer
в React - хороший пример управления состоянием. С помощью обоих этих хуков вы можете:
- сохранить начальное значение, вызвав хук
- прочитать текущее значение, также вызвав хук
- обновить значение, вызвав
setState
илиdispatch
- узнать, что значение было обновлено, потому что компонент перерендерился
Точно так же Redux и MobX также являются “state management”:
- Redux сохраняет начальное значение, вызывая корневой редьюсер, позволяет вам прочитать текущее значение с помощью
store.getState()
, обновляет значение с помощьюstore.dispatch(action)
и уведомляет слушателей, что хранилище обновлено черезstore.subscribe(listener)
- MobX сохраняет начальное значение, присваивая значения полей в классе хранилища, позволяет вам считывать текущее значение, обращаясь к полям хранилища, обновляет значения, обращаясь к соответствующим полям, и уведомляет об изменениях, произошедших с помощью
autorun()
иcomputed()
.
Мы даже можем сказать, что инструменты кэширования сервера, такие как React-Query, SWR, Apollo и Urql, подходят под определение «state management» - они хранят начальные значения на основе полученных данных, возвращают текущее значение через свои хуки, разрешают обновления через “server mutations” и уведомляют об изменениях посредством ререндера компонента.
React Context не соответствует этим критериям. Следовательно, Context - это не «state management» инструмент!
Как мы установили ранее, Context сам ничего не «хранит». Родительский компонент, который рендерит <MyContext.Provider>
, отвечает за решение, какое значение передать в контекст, и это значение обычно берется из состояния React компонента. Фактический «state management» происходит с помощью хука useState/useReducer
.
David Khourshid также говорит:
Context - это то, как состояние (которое где-то уже существует) передается с другими компонентами.
Context имеет мало общего с управлением состоянием.
Или, как сказано в недавнем твите:
Я предполагаю, что Context больше похож на скрытые пропы, чем на абстрактное состояние.
Подумайте об этом так. Мы могли бы написать точно такой же код c useState/useReducer
, но пробрасывая данные и функции обновления этих данных вниз по дереву компонентов через prop-drilling. Фактическое поведение приложения в целом было бы таким же. Все, что делает для нас Context - это позволяет нам пропустить prop-drilling.
Сравнение Context и Redux
Давайте посмотрим, какие возможности есть у Context и React+Redux:
Контекст
- Ничего не хранит и не "управляет"
- Работает только в React компонентах
- Передает одно значение, которое может быть любое (примитивом, объектами, классами и т.д.)
- Позволяет читать это единственное значение
- Может использоваться, чтобы избежать prop-drilling
- Показывает текущее значение контекста для компонентов
Provider
иConsumer
в React DevTools, но не показывает историю того, как это значение менялось с течением времени. - Обновляет “получающие” компоненты при изменении значения контекста, но без возможности пропустить обновления
- Не включает никаких механизмов для side эффектов - он исключительно для рендеринга компонентов
React+Redux
- Хранит и управляет одним значением (обычно это объект)
- Работает с любым пользовательским интерфейсом, в том числе вне React компонентов
- Позволяет читать это единственное значение
- Может использоваться, чтобы избежать prop-drilling
- Может обновлять значение путем dispatch экшена и запуска редьюсеров
- Имеет DevTools, которые показывают историю всех отправленных экшенов и изменений состояния с течением времени
- Использует middleware, чтобы код приложения мог вызывать side эффекты
- Позволяет компонентам подписаться на обновления хранилища, извлекать определенные части состояния хранилища и перерендериваться только при изменении этих значений
Итак, очевидно, что это очень разные инструменты с разными возможностями. Единственное совпадение между ними, на самом деле, это «можно использовать, чтобы избежать prop-drilling».
Context и useReducer
Одна из проблем при обсуждении «Context против Redux» заключается в том, что люди часто имеют в виду: «Я использую useReducer для управления своим состоянием и Context для передачи этого значения». Но они никогда не заявляют об этом явно - они просто говорят: «Я использую Context». Это частая причина путаницы, которую я вижу, и действительно печально, потому что она помогает продвигать идею, что Context «управляет состоянием».
Итак, поговорим конкретно о комбинации Context + useReducer. Да, Context + useReducer ужасно похож на Redux + React-Redux. У них обоих есть:
- Хранимое значение
- Функция редьюсера
- Dispatching экшенов
- способ пробрасывать это значение и читать его во вложенных компонентах
Тем не менее, есть ряд очень существенных различий в возможностях и поведении Context + useReducer и Redux + React-Redux. Я рассмотрел ключевые моменты в своих постах React, Redux и Context Behavior и (Mostly) Complete Guide to React Rendering Behavior.
Резюмируя здесь:
- Context + useReducer полагается на передачу текущего значения стейта через Context. React-Redux передает текущий экземпляр Redux хранилища через Context.
- Это означает, что когда
useReducer
создает новое значение стейта, все компоненты, которые подписаны на этот Context, будут принудительно перерендерены, даже если им нужна только часть данных. Это может привести к проблемам с производительностью в зависимости от размера значения стейта, количества компонентов, подписанных на эти данные, и частоты их ререндера. С React-Redux компоненты могут подписываться на определенные части сейта хранилища и перерендериваться только при изменении значений.
Кроме того, есть и другие важные отличия:
- Context + useReducer - это фичи React, поэтому их нельзя использовать за пределами React. Redux хранилище не зависит от какого-либо пользовательского интерфейса, поэтому его можно использовать отдельно от React.
- React DevTools позволяет просматривать текущее значение контекста, но не исторические значения или изменения во времени. Redux DevTools позволяет видеть все задиспатченные экшены, содержимое каждого экшена, состояние, которое существовало после обработки каждого экшена, а также различия между каждым состоянием во времени.
- useReducer не имеет middleware. Вы можете делать некоторые side эффекты с помощью useEffect в сочетании с useReducer, и я даже видел несколько попыток обернуть useReducer чем-то похожим на middleware, но оба они сильно ограничены по сравнению с функциональностью и возможностями Redux middleware.
Стоит повторить то, что Sebastian Markbage (архитектор основной команды React) сказал об использовании Context:
Мое личное резюме состоит в том, что новый Context готов к использованию для обновлений с низкой частотой (например, локализация/темы). Также хорошо использовать его, как и старый контекст, для статических значений, а затем распространять обновления через подписки. Он не готов к использованию в качестве замены распространения Flux-подобных состояний.
Есть много постов, в которых рекомендуется настроить несколько отдельных контекстов для разных фрагментов состояния, в целях сокращения ненужных ререндеров. Некоторые из них также предлагают добавить свои собственные «компоненты выбора контекста», которые требуют сочетания React.memo()
, useMemo()
и осторожного сплита, чтобы было два отдельных контекста для каждого сегмента состояния (один для данных, и один для сеттеров). Конечно, так можно писать код, но в этот момент вы просто пытаетесь переизобрести React-Redux.
Итак, хотя Context + useReducer на первый взгляд напоминает Redux + React-Redux... они не полностью эквивалентны и не могут по-настоящему заменить Redux!
Выбор правильного инструмента
Как я сказал ранее, очень важно понимать, какие проблемы решает инструмент, и знать, какие проблемы у вас есть, чтобы правильно выбрать правильный инструмент для решения ваших проблем.
Сводка вариантов использования
Давайте вспомним варианты использования для каждого из них:
Context
- Передача значения вложенным компонентам без prop-drilling
useReducer
- Управление достаточно сложным стейтом React компонентов с помощью функции редьюсера
Context + useReducer
- Управление достаточно сложным стейтом React компонентов с помощью функции редьюсера и передача значения вложенным компонентам без prop-drilling
Redux
- Управление от умеренного до очень сложного состояния с использованием функций редьюсера
- Отслеживание того, когда, почему и как состояние менялось с течением времени
- Желание написать логику управления состоянием полностью отдельно от уровня пользовательского интерфейса
- Совместное использование логики управления состоянием между разными слоями пользовательского интерфейса
- Использование возможностей Redux middleware для добавления дополнительной логики при отправке экшенов
- Возможность сохранять части Redux состояния
- Возможность получения отчетов об ошибках, которые могут воспроизводиться разработчиками
- Более быстрая отладка логики и пользовательского интерфейса в процессе разработки
Redux + React-Redux
- Все варианты использования Redux, а также взаимодействие с хранилищем Redux в ваших компонентах React
Опять же, это разные инструменты, которые решают разные проблемы!
Рекомендации
Итак, как вы решите, использовать ли Context, Context + useReducer или Redux + React-Redux?
Вам нужно определить, какой из этих инструментов лучше всего соответствует набору задач, которые вы пытаетесь решить!
- Если единственное, что вам нужно сделать, это избегать prop-drilling, используйте Context
- Если у вас есть достаточно сложное состояние React компонента или вы просто не хотите использовать внешнюю библиотеку, используйте Context + useReducer
- Если вам нужна прослеживаемость изменений вашего состояния с течением времени и необходимо убедиться, что только определенные компоненты повторно ререндерятся при изменении состояния, вам нужны более мощные возможности для управления side эффектами или есть другие подобные проблемы, используйте Redux + React-Redux
Мое личное мнение заключается в том, что если вы преодолеете 2-3 контекста, связанных с состоянием приложения, то вы заново изобретаете упрощенную версию React-Redux и должны просто переключиться на использование Redux.
Другая распространенная проблема заключается в том, что «использование Redux означает слишком много «бойлерплейта»». Эти жалобы сильно устарели, так как «современный Redux» значительно легче изучить и использовать, чем то, что вы, возможно, видели раньше. Наш официальный пакет Redux Toolkit устраняет эти «бойлерплейтные» проблемы, а API хуков React-Redux упрощает использование Redux в ваших React компонентах.
Добавление RTK (Redux Toolkit) и React-Redux в качестве зависимостей действительно добавляет дополнительный размер в байтах к вашему пакету приложений по сравнению с Context + useReducer, потому что они встроены в React. Но компромиссы того стоят - лучшая отслеживаемость состояния, более простая и предсказуемая логика и улучшенная производительность рендеринга компонентов.
Также важно отметить, что это не взаимоисключающие параметры - вы можете использовать Redux, Context и useReducer одновременно! Мы особенно рекомендуем помещать «глобальное состояние» в Redux и «локальное состояние» в React компоненты и тщательно решать, должна ли конкретная часть состояния находиться в Redux или всё таки в состоянии компонента. Таким образом, вы можете использовать Redux для некоторого глобального состояния и useReducer + Context для некоторого более локального состояния и Context отдельно для некоторых полустатических значений, всё в одном приложении.
Чтобы быть ясным, я не говорю, что все приложения должны использовать Redux, или что Redux всегда лучший выбор! В этой теме много нюансов. Я говорю, что Redux - правильный выбор, есть много причин для выбора Redux, и компромиссы при выборе Redux чаще всего приносят чистую выгоду, чем многие думают.
И наконец, Context и Redux - не единственные инструменты, о которых стоит задуматься. Есть много других инструментов, которые по-разному решают другие аспекты управления состоянием. MobX - еще один широко используемый вариант, который использует ООП и observables для автоматического обновления зависимостей. Jotai, Recoil и Zustand предлагают более легкие подходы к обновлению состояния. Библиотеки подгрузки данных, такие как React Query, SWR, Apollo и Urql, все предоставляют абстракции, которые упрощают общие паттерны для работы с кэшированным состоянием сервера (и готовящаяся библиотека «RTK Query» будет делать то же самое для Redux Toolkit). Опять же, это разные инструменты с разными целями и вариантами использования, и их стоит оценить в зависимости от вашего варианта использования.
Последние мысли
Я понимаю, что этот пост не остановит, казалось бы, нескончаемых дебатов по поводу «Context против Redux?!?!?!?!?». Слишком много людей, слишком много противоречивых идей, слишком много недопонимания и дезинформации.
Надеюсь, что этот пост прояснил, что на самом деле делают эти инструменты, чем они отличаются и когда вам действительно стоит подумать об их использовании. (И возможно, просто возможно, некоторые люди прочитают эту статью и не почувствуют необходимости публиковать тот же вопрос, который задавали уже миллион раз...)
Posted on January 25, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.