Logo
How to use React Context easily?

How to use React Context easily?

September 27, 2025
11 min read
index

Дисклеймер

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

Так же, если не понимаете о чем идет речь, ат олл, пожалуйста посмотрите на эти ресурсы: createContext, zustand, Passing Data Deeply with Context.

Введение

React Context - это потужный инструмент, но использовать его бездумно опасно. Он решает проблему prop-dtilling и отлично работает с Compound Components, но может превратиться в бутылочное горлышко для производительности. В этой статье разбеем, где контекст реально помогает, какие подводные камни вас ждут и как избежать лишних ре-рендеров, сохранив код чистым и гибким.

Для чего использовать контекст?

Если вы никогда не работали с контекстом, то у меня для вас плохие новости, вы не работали с самым мощным инструментом React, по мнению автора.

Контекст позволяет внедрять зависимости в ваше дерево компонентов, и как следствие позволяет избегать вложение пропсов на каждом уровне вложенности. Так же вся сила React Context проявляется с использованием паттерна составных компонентов (Compound Component Pattern).

Definition

Compound Components - это шаблон проектирования, широко используемый в React. В этом паттерне родительский компонент управляет общим состоянием и логикой всего компонента, а так же инжектирует её в дочерние компоненты, которые совместно формируют полноценный элемент пользовательского интерфейса. Этот шаблон создаёт более гибкий, многоразовый и выразительный API, чем передача множества свойств одному компоненту, что упрощает настройку и компоновку разработчиком с помощью компонента.

То есть все взаимодействие внутри Compound Components осуществляется, как правило, с помощью React Context.

Пример использования контекста

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

Сначало создаем интерфейс, который описывает тип и сам контекст:

src/ui/modal/modal-context.tsx
export interface ModalContextValue {
shown: boolean
onShow: () => void
onClose: () => void
}
export const ModalContext = createContext<ModalContextValue | null>(null)

Затем создадим провайдер для данного контекста:

src/ui/modal/modal-context-provider.tsx
export function ModalProvider(props: ModalProviderProps & PropsWithChildren) {
const [shown, setShown] = useState(props.defaultShown ?? false)
const onShow = () => {
setShown(true)
props.onChange?.(true)
}
const onClose = () => {
setShown(false)
props.onChange?.(false)
}
const value: ModalContextValue = {
shown,
onShow,
onClose,
}
return <ModalContext value={value} children={props.children} />
}
export function withModalProvider<TProps extends JSX.IntrinsicAttributes>() {
return (Component: ComponentType<TProps>) => {
return (props: TProps) => (
<ModalProvider>
<Component {...props} />
</ModalProvider>
)
}
}

А так же создадим вспомогательных хук, который упрощает работу с контекстом в компонентах консьюмера:

src/ui/modal/modal-context.tsx
export function useModal() {
const context = useContext(ModalContext)
if (context === null) {
throw new Error('The useModal hook must be used under the ModalProvider')
}
return context
}

Осталось имплементировать сами компоненты модального окна. Для простоты понимания некоторые компоненты, которые отвечают за layout (header, body, footer, overlay), не были отображены.

src/ui/modal/modal.tsx
export const Modal = () => null
Modal.Root = ModalProvider
Modal.Trigger = function ModalTrigger(props: ComponentProps<'button'>) {
const { onShow } = useModal()
return <button onClick={onShow} {...props} />
}
Modal.CloseButton = function ModalCloseButton(props: ComponentProps<'button'>) {
const { onClose } = useModal()
return <button onClick={onClose} {...props} />
}
Modal.Content = function ModalContent(props: ComponentProps<'div'>) {
const { shown } = useModal()
return shown ? <div data-shown={shown} {...props} /> : null
}

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

src/pages/index.page.tsx
export default function IndexPage() {
const onDisagreeClick = () => { /* redirect back */ }
return (
<main>
<Modal.Root defaultShown={true}>
<Modal.Content>
<b>Are you agree with out rules?</b>
<div data-footer>
<button onClick={onDisagreeClick}>No, disagree</button>
<Modal.CloseButton>Yes, I totally agree!</Modal.CloseButton>
</div>
</Modal.Content>
</Modal.Root>
<section data-some-content>...</section>
</main>
)
}

Или так:

src/widgets/profile-settings-modal.tsx
export function ProfileSettingsModal() {
const onSaveChanges = () => { /* Save changes */ }
return (
<Modal.Root>
<Modal.Trigger>
<Icon name="preferences" /> Settings
</Modal.Trigger>
<Modal.Content>
{/* User profile settings form */}
<div data-footer>
<Modal.CloseButton>Cancel</Modal.CloseButton>
<button onClick={onSaveChanges}>Save</button>
</div>
</Modal.Content>
</Modal.Root>
)
}

Как видно из примера, модальное окно можна использовать по разному, при этом сохраняя локаничность и простоту.

Только есть один нюанс, когда неоходимо работать с множеством Compound Components, которые используют контекст, например, dialogs, tabs, accordions, cards, select boxs, compobox, etc., создается много бойлерплейта, которого можна избежать.

Удобная работа с React Context

Можна создать фабрику контекстов, которая будет создавать сам контекст. провайдер и функцию обертку для провайдера, что минимизирует весь бойлерплейт.

Вот пример данной функции:

src/shared/react/create-context.tsx
import type {
ComponentType,
Context,
JSX,
PropsWithChildren,
ReactNode,
} from 'react'
import { createContext as createReactContext, useContext } from 'react'
import type { MinLength } from './string-util-types'
const DISPLAY_NAME_MIN_LENGTH = 2
export function createContext<DisplayName extends string = string>(
displayName: MinLength<DisplayName, typeof DISPLAY_NAME_MIN_LENGTH>,
) {
/**
* TODO: You can add different type guards here,
* such as minLengthOrThrow(displayName, DISPLAY_NAME_MIN_LENGTH) or capitalizeOrThrow(displayName)
*/
return function inner<
ContextValue,
ProviderProps = never | PropsWithChildren,
>(contextValueCallback: (providerProps?: ProviderProps) => ContextValue) {
const contextName = `${displayName as DisplayName}Context` as const
const providerName = `${displayName as DisplayName}Provider` as const
const withProviderName = `with${providerName}` as const
const hookName = `use${displayName as DisplayName}` as const
const Context = createReactContext<ContextValue | null>(null)
Context.displayName = contextName
function ContextProvider(
providerProps: ProviderProps & { children: ReactNode },
) {
const contextValue = contextValueCallback(providerProps)
return (
<Context.Provider
value={contextValue}
children={providerProps.children}
/>
)
}
function withContextProvider<ChildrenProps>(providerProps?: ProviderProps) {
return (Component: ComponentType<ChildrenProps>) =>
(props: ChildrenProps & JSX.IntrinsicAttributes) => {
const contextValue = contextValueCallback(providerProps)
return (
<Context.Provider value={contextValue}>
<Component {...props} />
</Context.Provider>
)
}
}
function useContextHook() {
const value = useContext(Context)
if (value === null) {
throw new Error(`The ${hookName} hook must be under ${providerName}`)
}
return value as ContextValue
}
return {
[contextName]: Context as Context<ContextValue>,
[providerName]: ContextProvider,
[withProviderName]: withContextProvider,
[hookName]: useContextHook,
} as {
[Key in typeof contextName]: Context<ContextValue>
} & {
[Key in typeof providerName]: typeof ContextProvider
} & {
[Key in typeof withProviderName]: typeof withContextProvider
} & {
[Key in typeof hookName]: typeof useContextHook
}
}
}

Теперь, исли создания контекста и всей инфраструктурных элементов, для модального окна, делается так:

src/ui/modal/modal-context-slice.tsx
export interface ModalContextValue {
shown: boolean
onShow: () => void
onClose: () => void
}
const modalContext = createContext('Modal')<ModalContextValue>(() => {
const [shown, setShown] = useState(false)
return {
shown,
onShow: () => setShown(true),
onClose: () => setShown(true),
}
})
export const {
ModalProvider,
withModalProvider,
useModal,
ModalContext,
} = modalContext

Как видно из примера, API никак не изменился, и модальное окно можна использовать как и раньше. Так же, с помощью данной функции, можна задекларировать пропсы для провайдера, например:

src/ui/modal/modal-context-slice.tsx
8 collapsed lines
import { useState } from 'react'
import { createContext } from './react-create-context'
export interface ModalContextValue {
shown: boolean
onShow: () => void
onClose: () => void
}
export interface ModalProviderProps {
defaultShown?: boolean
onChange?: (shown: boolean) => void
}
const modalContext = createContext('Modal')<
ModalContextValue,
ModalProviderProps
>((props) => {
const [shown, setShown] = useState(props?.defaultShown ?? false)
return {
shown,
onShow: () => {
props?.onChange?.(true)
setShown(true)
},
onClose: () => {
props?.onChange?.(false)
setShown(true)
},
}
})
6 collapsed lines
export const {
ModalProvider,
withModalProvider,
useModal,
ModalContext,
} = modalContext

Теперь, модальному окну можно прокинуть пропсы, и изменить логику её работы без боли. Также, такой метод генерации хуков используеться в популярной библеотеке как RTK Query (Redux Tool Kit Query):

rtk-query-example.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'
// Define a service using a base URL and expected endpoints
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: (build) => ({
getPokemonByName: build.query<Pokemon, string>({
query: (name) => `pokemon/${name}`,
}),
}),
})
// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = pokemonApi
export default function IndexPage() {
// Using a query hook automatically fetches data and returns query values
const { data, error, isLoading } = useGetPokemonByNameQuery('bulbasaur')
// Individual hooks are also accessible under the generated endpoints:
// const { data, error, isLoading } = pokemonApi.endpoints.getPokemonByName.useQuery('bulbasaur')
// render UI based on data and loading state
}

Минусы React Context и как их избежать

Минусы:

  1. Глобальный ре-рендер потребителей: Когда обновляется значение в Context.Provider, перерисовываются все компоненты, которые используют этот контекст через useContext, даже если они не зависят от обновлённого поля. Это может привести к заметным проблемам с производительностью в больших приложениях.
  2. Невидимые зависимости. Контекст может усложнить понимание кода: компонент явно не получает пропсы, но внутри использует useContext, понять, откуда пришли данные труднее чем с обычнми пропсами.
  3. Ограниченная область применения. Контекст плохо подходит для часто обновляемых данных (например, анимаций, быстро меняющихся значений).

Как избегать:

  1. Для решения проблемы лишних ре-рендеров можна разбивать контексты на более мелкие, например по геттерам и сеттерам, так как ссылка на сеттер всегда стабильна, компоненты которое меняют значение, не будут перерисовываься из-за изменения самих значений. Такох подход может сработать, но есть решение лучше, например использования библеотек, таких как use-context-selector, zustand или jotai.
  2. Чтоб зависимости всегда оставались явными, контекст предпочтительно использовать по модулям/компонентам. А для взаимодействия между страницами/разными виджетами, лучше воспользоваться стейт менеджером. Так же на ясность влияет хороший нейминг, например, если сравнить useModal и useCreateBuildingModal, то сразу понятно что первый хук отвечает за generic modal, а второй за конкретный flow создания какого-то здания (определяется бизнес доменом).
  3. Действительно, контекст это не панацея, а просто инструментом, и его нужно использовать с умом, как и все в нашей жизни 😊.

Реальный пример

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

Чуток требований для понимания:

  • Пользователь может редактировать текст в каждой ячейке гриды
  • Пользователь может менять форматирования/выравнивание текста
  • Пользователь может применять изменения для нескольких ячеек одновременно
  • Пользователь может обьеденять несколько ячеек между собой
  • Пользователь может добавлять/удалять столбцы/строки
  • Выделенные и активная ячейки выделены разными цветами
  • Пользователь может применять действия с помощью Control Panel and Context Menu
  • Некоторые действия с компонентом ограничены по пользовательской роли

Для реализации данного компонета, был выбран React Context как DI Container, а так же zustand, чтоб избежать лишних ререндеров. В этой связке, zustand предоставляет удобный API для работы со стором, а Context инкапсулирует стор, и внедряет его во все дочерние компоненты.

Базовая Реализация:

src/ui/data-grid/types.ts
import { StoreApi } from 'zustand'
import { GridItemData, type GridState } from '../grid-state'
import { Point, Rectangle } from '../primitives'
export type GridStoreContextValue = {
32 collapsed lines
gridState: GridState
loadGridState: (gridState: GridState) => void
getGridItem: (point: Point) => GridItemData | null
setGridItemData: (point: Point, values: Partial<GridItemData>) => void
addRow: (index?: number) => void
addCol: (index?: number) => void
removeRow: (index?: number, amount?: number) => void
removeCol: (index?: number, amount?: number) => void
merge: () => void
unmerge: () => void
getIsContainsMergedCells: () => boolean
getMergeParams: (point: Point) => {
colSpan: number
rowSpan: number
isIgnored: boolean
}
selection: null | Rectangle
setStartSelection: (point: Point) => void
updateEndSelection: (point: Point) => void
resetSelection: () => void
getIsSelectedCell: (point: Point) => boolean
activeCell: null | Point
setActiveCell: (point: Point) => void
resetActiveCell: () => void
getIsActiveCell: (point: Point) => boolean
}
export type GridStoreContextType = StoreApi<GridStoreContextValue> | null
export type GridStoreListener = Parameters<
StoreApi<GridStoreContextValue>['subscribe']
>[0]

Где GridStoreContextValue это тиа самого стора, а GridStoreContextType это тип который инжектируется в контекст.

Так выглядят создание контекста:

src/ui/data-grid/data-grid-context.ts
import { createContext } from "react";
import { GridStoreContextType } from "./types";
export const GridStoreContext = createContext<GridStoreContextType>(null);

И вспомогательне хуки, которые будут внедряться в дочерние компоненты:

src/ui/data-grid/data-grid-hook.ts
import { createContext } from 'react'
import { GridStoreContextType } from './types'
export const GridStoreContext = createContext<GridStoreContextType>(null)
src/ui/data-grid/data-grid-hook.ts
import { useContext } from 'react'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import { GridStoreContext } from './grid-store-context'
import { GridStoreContextType } from './types'
type ExtractState<S> = S extends { getState: () => infer T } ? T : never
type StoreSelector<TResult> = (
state: ExtractState<NonNullable<GridStoreContextType>>,
) => TResult
export function useGridStore<TResult>(
selector: StoreSelector<TResult>,
equalityFn?: (a: TResult, b: TResult) => boolean,
) {
const store = useContext(GridStoreContext)
if (!store) {
throw new Error('Missing GridStoreProvider')
}
return useStoreWithEqualityFn(store, selector, equalityFn)
}

Тут используется useStoreWithEqualityFn, а не useStore чтоб можна было прокинуть функцию сравнения для сложных обьектов, например:

src/ui/data-grid/data-grid-selectors.ts
import { shallow } from 'zustand/shallow'
import { useGridStore } from './data-grid-hook'
export const useGridSelection = () =>
useGridStore(
(store) => store.selection,
(prev, next) => shallow(prev?.p1, next?.p1) && shallow(prev?.p2, next?.p2),
)

Затем, инжектируем zustand store instance в контекст:

src/ui/data-grid/data-grid-store-provider.ts
export function GridStoreProvider(props: GridStoreProviderProps) {
const { children, defaultGridState } = props
const storeRef = useRef<GridStoreContextType>(null)
if (storeRef.current === null) {
storeRef.current = createStore<GridStoreContextValue>((set, get) => ({
28 collapsed lines
// Implementation is not provided
gridState: { ...defaultGridState },
loadGridState: (gridState) => {},
getGridItem: (point) => {},
setGridItemData: (point, values) => {},
addRow: (insertIndex) => {},
addCol: (insertIndex) => {},
removeRow: (removeIndex, amount = 1) => {},
removeCol: (removeIndex, amount = 1) => {},
merge: () => {},
unmerge: () => {},
getIsContainsMergedCells: () => {},
selection: null,
setStartSelection: (point) => {},
updateEndSelection: (point) => {},
resetSelection: () => {},
getIsSelectedCell: (point) => {},
activeCell: null,
setActiveCell: (point) => {},
resetActiveCell: () => {},
getIsActiveCell: (point) => {},
}))
}
return (
<GridStoreContext.Provider value={storeRef.current}>
{children}
</GridStoreContext.Provider>
)
}

Инстанс стора создается и сохраняется в ref, чтоб ссылка на стор была стабильна.

Затем необходимо создать разные компоненты, такие как DataGrid.ContextMenu, DataGrid.ControlPanel, DataGrid.EventHandler, DataGrid.Cell, etc. Где каждый дочерний компонент использует вспомогательный хук, например:

src/ui/data-grid/data-grid-cell.tsx
export function GridCell(props: GridItemProps) {
const { point, className, ...restProps } = props
const setGridItemData = useGridStore((store) => store.setGridItemData)
const isEditing = useGridStore((store) => store.getIsActiveCell(point))
const gridItem = useGridItem(point)
const onChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => setGridItemData(point, { value: event.target.value }),
[point],
)
return (
<div className={cn(s.wrapper, className)} {...restProps}>
{/* Implementation is not provided */}
</div>
)
}

Или элемент контрольной панели, для редактирования количества строк:

src/ui/data-grid/data-grid-control-panel/components/rows.tsx
export function RowsControls() {
const removeRow = useGridStore((store) => store.removeRow)
const addRow = useGridStore((store) => store.addRow)
const minHeight = useGridStore((store) => store.gridState.minHeight)
const height = useGridStore((store) => store.gridState.height)
return (
<Button.Group size="small" className={cn(s.group, s.rangeGroup)}>
<Button variant="secondary">Rows</Button>
<Button onClick={() => removeRow()} disabled={height <= minHeight}>
-
</Button>
<Button onClick={() => addRow()}>+</Button>
</Button.Group>
)
}

Так же можна скомбинировать внедрение zustand store в фабрику createReactContext, и получить минимум бойлерплейта.

Итоги

Таким образом, React Context + Compound Components ( + any atomic state manager if needed) = имба лютейшого масштаба хорошое решение для разработки инфраструктурных компонентов.

React Context - инжектирует зависимости в дочерние компоненты, инкапсулирует internal состояние компонента от чужих глаз. Compound Component - группирует компоненты по смыслу и логике. Atomic state managers - исправляют недостатки контекста, улучшают перформанс для сложных компонентов.

Иф ю хеав ани квешонс, плиз контакт виз афтор изинг зе имейл, сенк ю а лот! ❤️