Одной из самых распространенных архитектурных иллюзий в мире React является представление о том, что React Context — это встроенный инструмент управления глобальным состоянием (State Manager).
С точки зрения ядра React Fiber, React Context — это исключительно механизм внедрения зависимостей (Dependency Injection). Его задача — доставка неизменяемых или редко меняющихся конфигурационных данных (локаль, тема, токены, DI-сервисы) сквозь слои компонентов без Prop Drilling. Попытка хранить динамическое высокочастотное состояние в монолитном Context неизбежно приводит к каскадному шторму ререндеров, который невозможно остановить с помощью React.memo.
Использование монолитного контекста для динамического состояния вызывает три системные проблемы производительности:
useContext(MyContext) или use(MyContext), мемоизация React.memo полностью игнорируется: при изменении ссылки value в провайдере React Fiber принудительно ререндерит компонент.{ user, cart, theme, notifications }, изменение любого одного поля (например, добавление товара в корзину) вызовет принудительный рендеринг компонентов шапки, профиля, настроек темы и уведомлений.(state) => state.field, вынуждая инженеров либо дробить контексты (Context Splitting), либо строить селекторный мост через useSyncExternalStore.В исходном коде React Fiber механизм подписки на Context устроен следующим образом:
useContext React регистрирует узел в поле dependencies текущего Fiber.value в Provider изменилась, React помечает Fiber-узел и запускает его ререндер.// Наивный монолитный контекст: ререндерит всех подписчиков при любом апдейте
<AppContext.Provider value={{ user, cart, theme, dispatch }}>
// Архитектурно выверенный Split Context:
<CartStateContext.Provider value={cartState}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>Зафиксируйте в React DevTools Profiler, какие компоненты подсвечиваются при изменении контекста. Если частота обновлений превышает 5–10 раз в секунду — Context не подходит.
Убедитесь, что все компоненты-потребители передаются в провайдер через {children}, исключая пересоздание неподписанного дерева.
Разделите монолитный контекст на два независимых: StateContext для данных и DispatchContext/ActionsContext для функций.
Оберните значение value в useMemo, гарантируя неизменность ссылочного равенства Object.is при неизменных данных.
Если компонентам требуются отдельные поля крупного объекта, внедрите селекторный мост через createStoreContext.
Компонент Button обернут в React.memo(Button) и внутри вызывает const dispatch = useContext(CartContext). Почему кнопка повторно ререндерится при каждом изменении содержимого корзины, хотя её пропсы абсолютно неизменны?
TabsList, TabsTrigger, TabsContent) через легковесный контекст без утечки во внешнее состояние.В исходном коде React в фазе beginWork при проверке Fiber-узла логика React.memo проверяет только пропсы (shallowEqual(prevProps, nextProps)). Однако перед этим React проверяет список контекстных зависимостей:
// Псевдокод рантайма React Fiber в beginWork:
if (hasContextChanged(workInProgress.dependencies)) {
// Флаг принудительного обновления!
// Проверка мемоизации React.memo полностью игнорируется!
return updateFunctionComponent(...);
}Если Object.is(prevContextValue, nextContextValue) === false, компонент ререндерится всегда.
Чтобы защитить компоненты от паразитных обновлений, монолитное состояние разделяется на два провайдера:
'use client';
import React, { createContext, useContext, useReducer, useMemo } from 'react';
export interface CartItem {
readonly id: string;
readonly title: string;
readonly price: number;
}
export interface CartState {
readonly items: CartItem[];
readonly total: number;
}
export type CartAction =
| { type: 'ADD'; item: CartItem }
| { type: 'REMOVE'; id: string }
| { type: 'CLEAR' };
const CartStateContext = createContext<CartState | undefined>(undefined);
const CartDispatchContext = createContext<React.Dispatch<CartAction> | undefined>(undefined);
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD': {
const items = [...state.items, action.item];
return { items, total: items.reduce((sum, i) => sum + i.price, 0) };
}
case 'REMOVE': {
const items = state.items.filter((i) => i.id !== action.id);
return { items, total: items.reduce((sum, i) => sum + i.price, 0) };
}
case 'CLEAR':
return { items: [], total: 0 };
default:
return state;
}
}
export function CartProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });
const memoizedState = useMemo(() => state, [state]);
// dispatch гарантированно стабилен между рендерами в React!
return (
<CartStateContext.Provider value={memoizedState}>
<CartDispatchContext.Provider value={dispatch}>
{/* Дети передаются через props children: React пропускает их ререндер */}
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
// Строго типизированные хуки с валидацией инварианта присутствия в дереве
export function useCartState(): CartState {
const context = useContext(CartStateContext);
if (!context) {
throw new Error('[useCartState] Ошибка инварианта: вызов вне <CartProvider>');
}
return context;
}
export function useCartDispatch(): React.Dispatch<CartAction> {
const context = useContext(CartDispatchContext);
if (!context) {
throw new Error('[useCartDispatch] Ошибка инварианта: вызов вне <CartProvider>');
}
return context;
}Теперь кнопка «Добавить в корзину» подписывается только на useCartDispatch(). При изменении корзины компонент кнопки не совершит ни одного лишнего ререндера.
Если контекст содержит много полей, разделения на State/Dispatch недостаточно. Необходимы гранулярные селекторы без сторонних библиотек:
'use client';
import React, { createContext, useContext, useRef, useSyncExternalStore, useCallback } from 'react';
export interface StoreApi<T> {
getState: () => T;
setState: (fn: (prev: T) => T) => void;
subscribe: (listener: () => void) => () => void;
}
export function createStoreContext<T>() {
const Context = createContext<StoreApi<T> | null>(null);
function Provider({
initialState,
children,
}: {
initialState: T;
children: React.ReactNode;
}) {
const storeRef = useRef<StoreApi<T> | null>(null);
if (!storeRef.current) {
let state = initialState;
const listeners = new Set<() => void>();
storeRef.current = {
getState: () => state,
setState: (updater) => {
state = updater(state);
listeners.forEach((listener) => listener());
},
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
return <Context.Provider value={storeRef.current}>{children}</Context.Provider>;
}
function useSelector<Selected>(selector: (state: T) => Selected): Selected {
const store = useContext(Context);
if (!store) {
throw new Error('[useSelector] Хук обязан вызываться внутри соответствующего Provider');
}
const getSnapshot = useCallback(() => selector(store.getState()), [store, selector]);
return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
function useSetState(): StoreApi<T>['setState'] {
const store = useContext(Context);
if (!store) {
throw new Error('[useSetState] Хук обязан вызываться внутри соответствующего Provider');
}
return store.setState;
}
return { Provider, useSelector, useSetState };
}Provider создает неизменяемый контейнер storeRef, хранящий значение, множество слушателей и методы подписки. Ссылка на контекст стабильна навсегда.
Компонент вызывает useSelector(state => state.activeId). Нативный React-хук подписывается на слушатели через store.subscribe.
Вызывается setState(). Значение в памяти обновляется, а слушатели listeners уведомляются синхронно.
React сравнивает старый и новый снимок селектора через Object.is. Ререндерятся ТОЛЬКО те компоненты, чей результат селектора изменился.
Почему конструкция <MyProvider>{children}</MyProvider> предотвращает ререндеринг компонентов внутри children при обновлении состояния внутри MyProvider, тогда как явное объявление <MyProvider><Child /></MyProvider> внутри тела родителя приводит к ререндеру Child?
'use client';
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
export interface WizardData {
step: number;
customerEmail: string;
shippingAddress: string;
isCompleted: boolean;
}
export interface WizardActions {
goToNextStep: () => void;
goToPreviousStep: () => void;
updateData: (fields: Partial<Omit<WizardData, 'step' | 'isCompleted'>>) => void;
completeWizard: () => void;
}
const WizardStateContext = createContext<WizardData | null>(null);
const WizardActionsContext = createContext<WizardActions | null>(null);
export function WizardProvider({ children }: { children: React.ReactNode }) {
const [data, setData] = useState<WizardData>({
step: 1,
customerEmail: '',
shippingAddress: '',
isCompleted: false,
});
const goToNextStep = useCallback(() => {
setData((prev) => ({ ...prev, step: prev.step + 1 }));
}, []);
const goToPreviousStep = useCallback(() => {
setData((prev) => ({ ...prev, step: Math.max(1, prev.step - 1) }));
}, []);
const updateData = useCallback(
(fields: Partial<Omit<WizardData, 'step' | 'isCompleted'>>) => {
setData((prev) => ({ ...prev, ...fields }));
},
[]
);
const completeWizard = useCallback(() => {
setData((prev) => ({ ...prev, isCompleted: true }));
}, []);
// Стабильный объект действий: ни разу не пересоздается за время жизни компонента!
const actions = useMemo<WizardActions>(
() => ({
goToNextStep,
goToPreviousStep,
updateData,
completeWizard,
}),
[goToNextStep, goToPreviousStep, updateData, completeWizard]
);
return (
<WizardStateContext.Provider value={data}>
<WizardActionsContext.Provider value={actions}>
{/* children изолированы от ререндеров Provider */}
{children}
</WizardActionsContext.Provider>
</WizardStateContext.Provider>
);
}
export function useWizardState(): WizardData {
const ctx = useContext(WizardStateContext);
if (!ctx) {
throw new Error('[useWizardState] должен использоваться внутри <WizardProvider>');
}
return ctx;
}
export function useWizardActions(): WizardActions {
const ctx = useContext(WizardActionsContext);
if (!ctx) {
throw new Error('[useWizardActions] должен использоваться внутри <WizardProvider>');
}
return ctx;
}Передача { theme, toggleTheme } напрямую в value без useMemo создает новый инстанс объекта при каждом рендере родителя. Это приводит к тому, что все подписчики контекста будут ререндериться, даже если ни theme, ни toggleTheme вообще не изменились.
// ❌ АНТИПАТТЕРН: Новый объект создается на каждый рендер родителя
export function BadThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('dark');
// ВНИМАНИЕ: value каждый раз имеет новую ссылку в куче памяти!
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// ✅ ПРАВИЛЬНО: Обертка в useMemo для стабилизации ссылки
export function GoodThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState('dark');
const contextValue = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={contextValue}>
{children}
</ThemeContext.Provider>
);
}Запись в React Context значений, меняющихся несколько десятков раз в секунду (секундомеры, скролл страницы, анимация), вызывает моментальный ступор интерфейса, поскольку React вынужден синхронно обходить всё дерево подписчиков на каждый кадр.
// ❌ АНТИПАТТЕРН: Секундомер в общем контексте положит приложение
function TimerProvider({ children }: { children: React.ReactNode }) {
const [ms, setMs] = useState(0);
useEffect(() => {
const id = setInterval(() => setMs((v) => v + 10), 10);
return () => clearInterval(id);
}, []);
// Все подписчики будут ререндериться 100 раз в секунду!
return <TimerContext.Provider value={ms}>{children}</TimerContext.Provider>;
}
// ✅ ПРАВИЛЬНО: Использование Zustand transient updates или прямого DOM refЕсли объявить дочерние компоненты инлайново внутри тела провайдера, при любом обновлении провайдера все внутренние JSX-элементы будут пересоздаваться, даже если они вообще не используют useContext.
// ❌ АНТИПАТТЕРН: Дочерние компоненты захардкожены внутри провайдера
function BadParentProvider() {
const [state, setState] = useState(0);
return (
<MyContext.Provider value={state}>
<HeavySidebar /> {/* БУДЕТ РЕРЕНДЕРИТЬСЯ ВСЕГДА! */}
<HeavyContent /> {/* БУДЕТ РЕРЕНДЕРИТЬСЯ ВСЕГДА! */}
</MyContext.Provider>
);
}
// ✅ ПРАВИЛЬНО: Дети передаются через props children
function GoodParentProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState(0);
return <MyContext.Provider value={state}>{children}</MyContext.Provider>;
}Вызов useContext вне соответствующего Provider возвращает undefined. Если разработчик не выбрасывает явную ошибку инварианта, приложение упадет глубже в коде с труднодиагностируемым Cannot read properties of undefined.
// ❌ АНТИПАТТЕРН: Тихий undefined ведет к неожиданным падениям
export function useAuth() {
return useContext(AuthContext); // Вернет undefined, если забыли <AuthProvider>!
}
// ✅ ПРАВИЛЬНО: Явная проверка инварианта
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('[useAuth] Критическая ошибка: компонент должен находиться внутри <AuthProvider>');
}
return context;
}