Однією з найпоширеніших архітектурних ілюзій у світі React є уявлення про те, що React Context — це вбудований інструмент управління глобальним станом (State Manager).
З погляду ядра React Fiber, React Context — це виключно механізм впровадження залежностей (Dependency Injection). Його завдання — доставка незмінних або рідко змінюваних конфігураційних даних (локаль, тема, токени, DI-сервіси) крізь шари компонентів без Prop Drilling. Спроба зберігати динамічний високочастотний стан у монолітному Context неминуче призводить до каскадного шторму ререндерів, який неможливо зупинити за допомогою React.memo.
Використання монолітного контексту для динамічного стану викликає три системні проблеми продуктивності:
useContext(MyContext) або use(MyContext) у React 19, мемоізація 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;
}