Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
TL;DR
Context и Redux — это одно и тоже?
Нет. Это разные инструменты, делающие разные вещи и используемые в разных целях.
Является ли контекст инструментом «управления состоянием»?
Нет. Контекст — это форма внедрения зависимостей (dependency injection). Это транспортный механизм, который ничем не управляет. Любое «управление состоянием» осуществляется вручную, как правило, с помощью хуков useState()/useReducer().
Являются ли Context и useReducer() заменой Redux?
Нет. Они в чем-то похожи и частично пересекаются, но сильно отличаются в плане возможностей.
Когда следует использовать контекст?
Когда вы хотите сделать некоторые данные доступными для нескольких компонентов, но не хотите передавать эти данные в виде пропов на каждом уровне дерева компонентов.
Когда следует использовать Context и useReducer()?
Когда вам требуется управление состоянием умеренно сложного компонента в определенной части приложения.
Когда следует использовать Redux?
Redux наиболее полезен в следующих случаях:
- Большое количество компонентов с состояниями, в которых используются одни и те же данные
- Состояние приложение часто обновляется
- Сложная логика обновления состояния
- Приложение имеет среднюю или большую кодовую базу и над ним работает много людей
- Вы хотите знать когда, почему и как обновляется состояние приложения и иметь возможность визуализировать эти изменения
- Вам требуются более мощные возможности по управлению побочными эффектами, стабильностью (постоянством) и сериализацией данных
Понимание Context и Redux
Для правильного использования инструмента критически важно понимать:
- Для чего он предназначен
- Какие задачи он решает
- Когда и зачем он был создан
Также важно понимать, какие задачи вы пытаетесь решить в вашем приложениии, и использовать те интструменты, которые решают их наилучшим образом, а не потому, что кто-то сказал вам их использовать, и не потому, что они являются популярными, а потому, что они лучше всего подходят вам в определенной ситуации.
Неразбериха вокруг Context и Redux связана, в первую очередь, с непониманием того, для чего данные инструменты предназначены, и какие задачи они решают. Поэтому, прежде чем говорить о том, когда их следует использовать, необходимо определить, что они из себя представляют и какие проблемы решают.
Что такое контекст?
Начнем с определения контекста из официальной документации:
«Контекст позволяет передавать данные через дерево компонентов без необходимости передавать пропы на промежуточных уровнях.
В типичном React-приложении данные передаются сверху вниз (от предка к потомку) с помощью пропов. Однако, этот способ может быть чересчур громоздким для некоторых типов пропов (например, выбранный язык, тема интерфейса), которые необходимо передавать во многие компоненты в приложении. Контекст предоставляет способ распределять такие данные между компонентами без необходимости явно передавать пропы на каждом уровне дерева компонентов».
Обратите внимание, в данном определении ни слова не говорится об «управлении», только о «передаче» и «распределении».
Текущее API контекста (React.createContext()) впервые было представлено в React 16.3 в качестве замены устаревшего API, доступного в ранних версиях React, но имеющего несколько недостатков дизайна. Одной из главных проблемой являлось то, что обновления значений, переданных через контекст, могли быть «заблокированы», если компонент пропускал рендеринг через shouldComponentUpdate(). Поскольку многие компоненты прибегали к shouldComponentUpdate() в целях оптимизации, передача данных через контекст становилась бесполезной. createContext() был спроектирован для решения этой проблемы, поэтому любое обновление значения отразится на дочерних компонентах, даже если промежуточный компонент пропускает рендеринг.
Использование контекста
Использование контекста в приложении предполагает следующее:
- Вызываем const MyContext = React.createContext() для создания экземпляра объекта контекста
- В родительском компоненте рендерим <MyContext.Provider value={someValue}>. Это помещает некоторые данные в контекст. Эти данные могут быть чем угодно: строкой, числом, объектом, массивом, экземпляром класса, обработчиком событий и т.д.
- Получаем значение контекста в любом компоненте внутри провайдера, вызывая const theContextValue = useContext(MyContext)
При обновлении родительского компонента и передачи новой ссылки в качестве значения провайдера, любой компонент, «потребляющий» контекст, будет принудительно обновлен.
Обычно, значением контекста является состояние компонента:
import { createContext } from 'react'
export const MyContext = createContext()
export function ParentComponent({ children }) {
const [counter, setCounter] = useState(0)
return (
<MyContext.Provider value={[counter, setCounter]}>
{children}
</MyContext.Provider>
)
}
После этого дочерний компонент может вызвать хук useContext() и прочитать значение контекста:
import { useContext } from 'react'
import { MyContext } from './MyContext'
export function NestedChildComponent() {
const [counter, setCounter] = useContext(MyContext)
// ...
}
Цель и случаи использования контекста
Мы видим, что контекст, в действительности, ничем не управляет. Вместо этого, он представляет собой своего рода тоннель (pipe). Вы помещаете данные в начало (наверх) тоннеля с помощью <MyContext.Provider>, затем эти данные опускаются вниз до тех пор, пока компонет не запросит их с помощью useContext(MyContext).
Таким образом, основная цель контекста состоит в предотвращении «бурения пропов» (prop-drilling). Вместо передачи данных в виде пропов на каждом уровне дерева компонентов, любой компонент, вложенный в <MyContext.Provider>, может получить к ним доступ посредством useContext(MyContext). Это избавляет от необходимости писать код, реализующий логику передачи пропов.
Концептуально, это является формой внедрения зависимостей. Мы знаем, что потомок нуждается в данных определенного типа, но он не пытается создавать или устанавливать эти данные самостоятельно. Вместо этого, он полагается на то, что некоторый предок передаст эти данные во время выполнения (runtime).
Что такое Redux?
Вот о чем гласит определение из «Основ Redux»:
«Redux — это паттерн проектирования и библиотека для управления и обновления состояния приложения, использующая события, именуемые операциями (действиями, actions). Redux выступает в роли централизованного хранилища состояния приложения, следюущего правилам, позволяющим обеспечить предсказуемое обновление состояние.
Redux позволяет управлять „глобальным“ состоянием — состоянием, которое трубется нескольким частям приложения.
Паттерны и инструменты, предоставляемые Redux, позволяют легче определять где, когда, почему и как было обновлено состояние, и как отреагировало приложение на это изменение».
Обратите внимание, что данное описание указывает на:
- Управление состоянием
- Цель Redux — определение того, почему и как произошло изменение состояния
Изначально Redux являлся реализацией «архитектуры Flux», паттерна проектирования, разработанного Facebook в 2014 году, через год после появления React. После появления Flux, сообщество разработало множество библиотек, по-разному реализующих данную концепцию. Redux появился в 2015 году и быстро стал победителем в этом соревновании благодаря продуманному дизайну, решению наиболее распространенных проблем и отличной совместимости с React.
Архитектурно, Redux подчеркнуто использует принципы функционального программирования, что позволяет писать код в форме предсказуемых «функций-редукторов» (reducers), и обособлять идею «какое событие произошло» от логики, определяющей «как обновляется состояние при возгникновении данного события». В Redux также используется промежуточное программное обеспечение (middleware) как способ расширения возможностей хранилища, включая обработку побочных эффектов (side effects).
Redux также предоставляет инструменты разработчика, позволяющие изучать историю операций и изменение состояния в течение времени.
Redux и React
Сам по себе Redux не зависит от UI — вы можете использовать его с любым слоем представления (view layer) (React, Vue, Angular, ванильный JS и т.д.) либо без UI вообще.
Однако, чаще всего, Redux используется совместно с React. Библиотека React Redux — это официальный связывающий слой UI, позволяющий React-компонентам взаимодействовать с хранилищем Redux, получая значения из состояния Redux и инициализируя выполнение операций. React-Redux использует контекст в своих внутренних механизмах. Тем не менее, следует отметить, что React-Redux передает через контекст экземпляр хранилища Redux, а не текущее значение состояния! Это пример использования контекста для внедрения зависимостей. Мы знаем, что наши подключенные к Redux компоненты нуждаются во взаимодействии с хранилищем Redux, но мы не знаем или нам неважно, что это за хранилище, когда мы определяем компонент. Настоящее хранилище Redux внедряется в дерево во время выполнения с помощью компонента <Provider>, предоставляемого React-Redux.
Следовательно, React-Redux также может быть использован для предотвращения «бурения» (по причине внутреннего использования контекста). Вместо явной передачи нового значения через <MyContext.Provider>, мы можем поместить эти данные в хранилище Redux и затем получить их в нужном компоненте.
Цель и случаи использования (React-)Redux
Основное назначение Redux согласно официальной документации:
«Паттерны и инструменты, предоставляемые Redux, облегчают понимание того, когда, где, почему и как произошло изменение состояния, а также того, как на это отреагировало приложение».
Существует еще несколько причин использования Redux. Одной из таких причин является предотвращение «бурения».
Другие случаи использования:
- Полное разделение логики управления состоянием и слоя UI
- Распределение логики управления состоянием между разными слоями UI (например, в процессе перевода приложения с AngularJS на React)
- Использование возможностей Redux middleware для добавления дополнительной логики при инициализации операций
- Возможность сохранения частей состояния Redux
- Возможность получения отчетов об ошибках, которые могут быть воспроизведены другими разработчиками
- Возможность быстрой отладки логики и UI во время разработки
Дэн Абрамов перечислил эти случаи в статье 2016 года «Почему вам может быть не нужен Redux».
Почему контекст не является инструментом «управления состоянием»?
Состояние — это любые данные, описывающие поведение приложения. Мы можем разделить состояние на такие категории, как состояние сервера, состояние коммуникаций и локальное состояние, если хотим, но ключевым аспектом является хранение, чтение, обновление и использование данных.
David Khourshid, автор библиотеки XState и специалист по управлению состоянием, в одном из своих твитов отметил, что:
«Управление состоянием — это изменение состояния в течение времени».
Таким образом, мы можем сказать, что «управление состоянием» означает следующее:
- Сохранение начального значения
- Получение текущего значения
- Обновление значения
Также, как правило, существует способ получения уведомлений об изменении текущего значения состояния.
React-хуки useState() и useReducer() являются отличными примерами управления состоянием. С помощью этих хуков мы можем:
- Сохранять начальное значение путем вызова хука
- Получать текущее значение также посредством вызова хука
- Обновлять значение, вызывая функцию setState() или dispatch(), соответственно
- Узнавать об обновлении состояния благодаря повторному рендерингу компонента
Redux и MobX также позволяют управлять состоянием:
- Redux сохраняет начальное значение путем вызова корневого редуктора (root reducer), позволяет читать текущее значение с помощью store.getState(), обновлять значение с помощью store.dispatch(action) и получать уведомления об обновлении состояния через store.subscribe(listener)
- MobX сохраняет начальное значение путем присвоения значения полю класса хранилища, позволяет читать текущее значение и обновлять его через поля хранилища и получать учведомления об обновлении состояния с помощью методов autorun() и computed()
К инструментам управления состоянием можно причислить даже инструменты для работы с кэшем сервера, такие как React-Query, SWR, Apollo и Urql — они сохраняют начальное значение на основе полученных (fetched) данных, возвращают текущее значение с помощью хуков, позволяют обновлять значения посредством «серверных мутаций» и уведомляют об изменениях с помощью повторного рендеринга компонента.
React Context не соответствует названным критериям. Поэтому он не является инструментом управления состоянием
Как было отмечено ранее, контекст сам по себе ничего не хранит. За передачу значения, которое, обычно, зависит от состояния компонента, в контекст отвечает родительский компонент, который рендерит <MyContext.Provider>. Настоящее «управление состоянием» происходит при использовании хуков useState()/useReducer().
David Khourshid также отмечает:
«Контекст — это то, как существующее состояние распределяется между компонентами. Контекст ничего не делает с состоянием».
И в более позднем твите:
«Полагаю, контекст — это как скрытые пропы, абстрагирующие состояние».
Все, что делает контекст, это позволяет избежать «бурения».
Сравнение Context и Redux
Сравним возможности контекста и React+Redux:
- Context
-
- Ничего не хранит и ничем не управляет
- Работает только в компонентах React
- Передает ниже простое (единственное) значение, которое может быть чем угодно (примитивом, объектом, классом и т.д.)
- Позволяет читать это простое значение
- Может использоваться для предотвращения «бурения»
- Показывает текущее значение для компонентов Provider и Consumer в инструментах разработчика, но не показывает историю изменений этого значения
- Обновляет «потребляющие» компоненты при изменении значения, но не позволяет пропустить обновление
- Не предоставляет механизма для обработки побочных эффектов — отвечает только за рендеринг
- React+Redux
-
- Хранит и управляет простым значением (обычно, этим значением является объект)
- Работает с любым UI, а также за пределами React-компонентов
- Позволяет читать это простое значение
- Может использоваться для предотвращения «бурения»
- Может обновлять значение путем инициализации операций и запуска редукторов
- Инструменты разработчика показывают историю инициализации операций и изменения состояния
- Предоставляет возможность использования middleware для обработки побочных эффектов
- Позволяет компонентам подписываться на обновления хранилища, извлекать определенные части состояния хранилища и контролировать повторный рендеринг компонентов
Очевидно, что это совершенно разные инструменты с разными возможностями. Единственной точкой пересечения между ними является предотвращение «бурения».
Context и useReducer()
Одной из проблем в дискуссии «Context против Redux» является то, что люди, зачастую, на самом деле имеют ввиду следующее: «Я использую useReducer() для управления состоянием и контекст для передачи значения». Но, вместо этого, они просто говорят: «Я использую контекст». В этом, на мой взгляд, кроется основная причина неразберихи, способствующая поддержанию мифа о том, что контекст «управляет состоянием».
Рассмотрим комбинацию Context + useReducer(). Да, такая комбинация выглядит очень похоже на Redux + React-Redux. Обе эти комбинации имеют:
- Сохраненное значение
- Функцию-редуктор
- Возможность инициализации операций
- Возможность передачи значения и его чтения во вложенных компонентах
Тем не менее, между ними по-прежнему существуют некоторые важные отличия, проявляющиеся в их возможностях и поведении. Я отметил эти отличия в статьях «Поведение React, Redux и Context» и "(Почти) полное руководство по рендерингу в React". Суммируя, можно отметить следующее:
- Context + useReducer() основан на передаче текущего значения через контекст. React-Redux передает через контекст текущий экземпляр хранилища Redux
- Это означает, что когда useReducer() производит новое значение, все компоненты, подписанные на контекст, принудительно перерисовываются, даже если они используют только часть данных. Это может привести к проблемам с производительностью в зависимости от размера значения состояния, количества подписанных компонентов и частоты повторного рендеринга. При использовании React-Redux компоненты могут подписываться на определенную часть значения хранилища и перерисовываться только при изменении этой части
Существуют и другие важные отличия:
- Контекст + useReducer() являются встроенными возможностями React и не могут использоваться за его пределами. Хранилище Redux не зависит от UI, поэтому может использоваться отдельно от React
- React DevTools показывают текущее значение контекста, но не историю его изменений. Redux DevTools показывают все инициализированные операции, их содержимое (тип и полезную нагрузку, type and payload), состояние после каждой операции и разницу между состояниями
- useReducer() не имеет middleware. Некоторые побочные эффекты можно обработать с помощью хука useEffect() в сочетании с useReducer(), я даже встречал отдельные попытки оборачивания useReducer() в нечто похожее на middleware, однако всему этому далеко до функционала и возможностей Redux middleware
Вот, что сказал Sebastian Markbage (архитектор команды ядра React) об использовании контекста:
«Мое личное мнение состоит в то, что новый контекст готов к использованию для маловероятных обновлений с низкой частотой (таких как локализация или тема). Он также может использоваться во всех тех случаях, в которых использовался старый контекст, т.е. для статических значений с последующим распространением обновления по подпискам. Он не готов к использованию в качестве замены Flux-подобных „распространителей“ состояния».
В сети существует много статей, рекомендующих настройку нескольких отдельных контекстов для разных частей состояния, что позволяет избежать ненужных повторных рендерингов и решить проблемы, связанные с областями видимости. Некоторые из постов также предлагают добавлять собственные «компоненты по выбору контекста», что требует использования сочетания React.memo(), useMemo() и аккуратного разделения кода на два контекста для каждой части приложения (одна для данных, другая для функций обновления). Безусловно, код можно писать и так, но в этом случае вы заново изобретаете React-Redux.
Таким образом, несмотря на то, что Context + useReducer() — это легкая альтернатива Redux + React-Redux в первом приближении… эти комбинации не идентичны, контекст + useReducer() не может полностью заменить Redux!
Выбор правильного инструмента
Для выбора правильного инструмента очень важно понимать, какие задачи решает инструмент, а также какие задачи стоят перед вами.
Обзор случаев использования
- Context
- Передача данных вложенным компонентам без «бурения»
- useReducer()
- Управление состоянием сложного компонента с помощью функции-редуктора
- Context + useReducer()
- Управление состоянием сложного компонента с помощью функции-редуктора и передача состояния вложенным компонентам без «бурения»
- Redux
- Управление очень сложным состоянием с помощью функций-редукторов
- Прослеживаемость того, когда, почему и как менялось состояние в течение времени
- Желание полной изоляции логики управления состоянием от слоя UI
- Распределение логики управления состоянием между разными слоями UI
- Использование возможностей middleware для реализации дополнительной логики при инициализации операций
- Возможность сохранения определенных частей состояния
- Возможность получения воспроизводимых отчетов об ошибках
- Возможность быстрой отладки логики и UI в процессе разработки
- Redux + React-Redux
- Все случаи использования Redux + возможность взаимодействия React-компонентов с хранилищем Redux
Еще раз: названные инструменты решают разные задачи!
Рекомендации
Как же решить, что следует использовать?
Для этого вам всего лишь нужно определить, какой инструмент наилучшим образом решает задачи вашего приложения.
- Если вам требуется просто избежать «бурения», используйте контекст
- Если у вас имеется сложное состояние, но вы не хотите использовать сторонние библиотеки, используйте контекст + useReducer()
- Если вам требуется хорошая трассировка изменений состояния во времени, управлемый повторный рендеринг определенных компонентов, более мощные возможности обработки побочных эффектов и т.п., используйте Redux + React-Redux
Я считаю, что если в вашем приложении имеется 2-3 контекста для управления состоянием, то вам следует переключиться на Redux.
Часто можно услышать, что «использование Redux предполагает написание большого количества шаблонного кода», однако, «современный Redux» значительно облегчает изучение данного инструмента и его использование. Официальный пакет Redux Toolkit решает проблему шаблонизации, а хуки React-Redux упрощают использование Redux в компонентах React.
Разумеется, добавление RTK и React-Redux в качестве зависимостей увеличивает «бандл» приложения по сравнению с контекстом + useReducer(), которые являются встроенными. Но преимущества такого подхода перекрывают недостатки — лучшая трассировка состояния, простая и более предсказуемая логика, улучшенная оптимизация рендринга компонентов.
Также важно отметить, что одно не исключает другого — вы можете использовать Redux, Context и useReducer() вместе. Мы рекомендуем хранить «глобальное» состояние в Redux, а локальное — в компонентах и внимательно подходить к определению того, какая часть приложения должна храниться в Redux, а какая — в компонентах. Так что вы можете использовать Redux для хранения глобального состояния, Context + useReducer() — для хранения локального состояния, и Context — для статических значений, одновременно и в одном приложении.
Еще раз: я не утверждаю, что все состояние приложения должно храниться в Redux или что Redux — это всегда лучшее решение. Я утверждаю лишь, что Redux — хороший выбор, существует много причин использовать Redux, и плата за его использование не так высока, как многие думают.
Наконец, контекст и Redux не единственные в своем роде. Существует множество других инструментов, решающих иные аспекты управления состоянием. MobX — популярное решение, использующее ООП и наблюдаемые объекты (observables) для автоматического обновления зависимостей. Среди других подходов к обновлению состояния можно назвать Jotai, Recoil и Zustand. Библиотеки для работы с данными, вроде React Query, SWR, Apollo и Urql, предоставляют абстракции, упрощающие применение распространенных паттернов для работы с состоянием, кэшируемым сервером (скоро похожая библиотека (RTK Query) появится и для Redux Toolkit).
Надеюсь, данная статья помогла вам понять разницу между контекстом и Redux, а также какой инструмент и в каких случаях следует использовать. Благодарю за внимание.