Команда приложения онлайн-магазина месяц жила спокойно, пока в интерфейсе не появился третий уровень вложенности. Выбранная в шапке тема оформления внезапно понадобилась кнопке глубоко внутри модального окна корзины, и значение поехало вниз по цепочке пропсов через компоненты, которым оно вовсе не нужно. На пятом звене кто-то переименовал проп, и половина экрана перестала перекрашиваться в тёмный режим. Это классическая боль проброса пропсов, и именно от неё лечит встроенный механизм React. Context не требует внешних зависимостей, приходит вместе с фреймворком и закрывает потребность в глобальном сторе для большинства приложений среднего размера.

Проблема проброса пропсов и появление Context в React 16.3

Пропс, который путешествует через пять-шесть промежуточных компонентов ради одного адресата, в сообществе называют пробросом пропсов. Промежуточные звенья сами не используют значение, но обязаны объявить его в списке параметров и пробросить дальше. Чем больше сквозных сущностей, тем гуще связи: тема, язык интерфейса, сеанс, корзина и настройки доступности тянутся через всё дерево и опутывают компоненты.

Исторически во фреймворке уже был экспериментальный контекст, но его не рекомендовали к применению из-за врождённых проблем самой абстракции. Всё изменила версия 16.3.0, вышедшая 29 марта 2018 года: она принесла новый интерфейс на основе функции createContext, более производительный и дружелюбный к статической типизации. Старый вариант продолжал работать во всех релизах ветки 16.x, давая время на переезд. Затем 6 февраля 2019 года вышла версия 16.8.0 с хуками, среди которых оказался useContext, и чтение контекста сжалось до одной строки. Актуальная версия библиотеки в реестре npm - 19.3.1.

Устройство механизма от createContext до ближайшего Provider в дереве

Контекст создаётся вызовом createContext с запасным значением, причём вызывать функцию принято вне компонентов, на уровне модуля. Главная тонкость в том, что сам объект контекста не хранит информацию: он лишь описывает вид информации, которую одни компоненты могут предоставить, а другие прочитать. Значение попадает в дерево через компонент Provider, а наверх извлекается хуком useContext.

Хук возвращает значение ближайшего провайдера, расположенного выше вызывающего компонента. Если провайдера в дереве нет, возвратится запасное значение, переданное в createContext при создании. Это значение статично: React использует его только как запасной вариант и не даст изменить его через API. Провайдеры одного контекста разрешено вкладывать друг в друга, побеждает ближайший: так включают отдельную тему для области предпросмотра письма.

import { createContext, useContext } from "react";

const ThemeContext = createContext("light");

function OrderButton() {
  const theme = useContext(ThemeContext);
  return <button className={"btn-" + theme}>Оформить заказ</button>;
}

export default function Page({ children }) {
  return (
    <ThemeContext.Provider value="dark">
      <OrderButton />
      {children}
    </ThemeContext.Provider>
  );
}

Кнопка получит строку dark, хотя контекст создан со значением light. Если провайдер убрать, кнопка сама откатится к light без правок в её коде. Типичные кандидаты на контекст - тема, локаль, сеанс, корзина.

Рабочий пример переключателя темы с собственным контекстом

Запасное значение статично, поэтому живые данные требуют состояния. Типовая связка: useState у корневого компонента, Provider со значением из состояния и хук-обёртка для чтения из глубин дерева.

import { createContext, useContext, useMemo, useState } from "react";

type Theme = "light" | "dark";

const ThemeContext = createContext<{ theme: Theme; toggle: () => void } | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  const value = useMemo(
    () => ({
      theme,
      toggle: () => setTheme(current => (current === "light" ? "dark" : "light"))
    }),
    [theme]
  );
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === null) {
    throw new Error("useTheme вызывается только внутри ThemeProvider");
  }
  return context;
}

Проверка на null в обёртке - не перестраховка. Понятная ошибка при первом запуске экономит час отладки тому, кто вызвал хук вне дерева провайдера и получил сбой при обращении к полям null. Наружу принято экспортировать только хук useTheme, а объект контекста прятать в модуле: это исключает чтение в обход обёртки. Обёртка value в useMemo - необходимость, причина раскроется в разделе о перерисовках.

Паттерн Context плюс useReducer с двумя разными контекстами

Когда значений много и логика обновлений разрастается, одного useState уже не хватает. Фреймворк предлагает паттерн из трёх шагов: создать контексты, положить в них состояние и функцию отправки действий, затем использовать их в любом месте дерева. Контекстов именно два: первый хранит состояние, второй - стабильную функцию dispatch. Компоненту, которому нужны только отправки, например форме добавления задачи, достаточно второго контекста, и он не перерисовывается при изменении списка.

import { createContext, useContext, useReducer, type Dispatch } from "react";

type Task = { id: number; text: string; done: boolean };
type Action =
  | { type: "added"; id: number; text: string }
  | { type: "toggled"; id: number };

export const TasksContext = createContext<Task[]>([]);
export const TasksDispatchContext = createContext<Dispatch<Action>>(() => {});

function tasksReducer(tasks: Task[], action: Action): Task[] {
  switch (action.type) {
    case "added":
      return [...tasks, { id: action.id, text: action.text, done: false }];
    case "toggled":
      return tasks.map(task =>
        task.id === action.id ? { ...task, done: !task.done } : task
      );
    default:
      throw new Error("Неизвестный тип действия: " + action.type);
  }
}

export function TasksProvider({ children }: { children: React.ReactNode }) {
  const [tasks, dispatch] = useReducer(tasksReducer, []);
  return (
    <TasksDispatchContext.Provider value={dispatch}>
      <TasksContext.Provider value={tasks}>{children}</TasksContext.Provider>
    </TasksDispatchContext.Provider>
  );
}

export const useTasks = () => useContext(TasksContext);
export const useTasksDispatch = () => useContext(TasksDispatchContext);

Функция reducer чистая: принимает предыдущее состояние и объект действия, возвращает новое, не трогая старое. Такую функцию легко покрыть тестами без запуска браузера, а действия складываются в понятный протокол изменений. По сути выходит маленький стор с командами и точкой входа, но без внешних зависимостей.

Ловушки перерисовок когда Provider получает новое значение

У простоты контекста есть цена, и имя ей - перерисовка. Когда провайдер получает новое значение, React автоматически перерисовывает всех потомков, которые читают этот контекст, начиная с самого провайдера. Сравнение старого и нового значения идёт через Object.is, то есть по ссылке. Обёртка memo не спасает: она не мешает компонентам внутри получать свежее значение контекста. Из этой механики вырастают три частых сюжета с потерей производительности.

Первый - литерал объекта прямо в атрибуте value. Запись value={{ user, cart }} создаёт новую ссылку при каждом рендере провайдера, сравнение по ссылке видит разницу, и все подписчики перерисовываются, даже если ни одно поле не поменялось. Лечение знакомое - обёртка const value = useMemo(() => ({ user, cart }), [user, cart]), которая пересобирает объект только при изменении зависимостей.

Второй сюжет - один толстый контекст на всё приложение. Если компоненту нужно одно поле из двадцати, он всё равно перерисуется при смене любого из них: подписка идёт на весь объект. Отсюда совет держать отдельный контекст под каждую независимую сущность и не сваливать темы, сеансы и корзины в один мешок. Разделение состояния и функции отправки выше - частный случай того же правила.

Третий сюжет самый коварный: дублирование модулей в сборке. Если сборка из-за симлинков включит файл с контекстом дважды, в бандле окажутся два разных объекта, и провайдер из одной копии станет невидим для читателей из второй. Контекст работает, лишь когда объект предоставления и объект чтения совпадают по строгому равенству ссылок. Ошибка проявляется странно: провайдер вроде бы есть, а подписчики упрямо видят запасное значение.

Есть и хорошая новость: перерисовка провайдера не тянет за собой всю ветку. Если дочерние элементы приходят снаружи через проп children, React сравнивает их по ссылке и пропускает лишнюю работу, а подписчики внутри всё равно получают свежее значение.

Границы Context и признаки что проекту нужен Redux Toolkit

Контекст - механизм доставки данных, а не менеджер состояния. Пока данные меняются нечасто и читаются широкой аудиторией компонентов, встроенного решения хватает с запасом. Но у контекста нет селекторов, производных значений и тонких подписок на отдельные поля. Как только каждое нажатие клавиши в поиске заставляет перерисовываться всех читателей, экономия на зависимостях оборачивается потерей кадров.

В реестре npm сегодня живут Redux Toolkit 2.13.0, ядро Redux 5.0.1, Zustand 5.0.15 и Jotai 3.0.1. Признаки, что пора переехать на специализированный стор, перечислены ниже:

  1. Состояние обновляется часто, и профилировщик React DevTools показывает заметные потери на перерисовках подписчиков;
  2. Компонентам нужны производные значения и селекторы, а не только прямое чтение общего объекта;
  3. Сущности требуют нормализации со связями по идентификаторам, как в базе данных;
  4. Команде нужен протокол изменений с расширяемыми действиями и историей для отладки;
  5. Данные живут вне дерева компонентов и приходят из внешнего хранилища с подписками.

Для сравнения ниже показан минимальный стор Zustand:

import { create } from "zustand";

type CartStore = {
  items: string[];
  coupon: string;
  addItem: (id: string) => void;
};

export const useCart = create<CartStore>(set => ({
  items: [],
  coupon: "",
  addItem: id => set(state => ({ items: [...state.items, id] }))
}));

function CartCount() {
  const items = useCart(state => state.items);
  return <span>Товаров в корзине: {items.length}</span>;
}

Компонент CartCount подписан только на поле items, и смена купона его не тронет. С контекстом точечной подписки нет: любой читатель перерисуется при любом изменении значения целиком. Если такая точность не нужна, тянуть сторонний стор нет смысла.

Новшества React 19 хук use и провайдер без суффикса Provider

Версия 19.0, вышедшая 5 декабря 2024 года, упростила работу с контекстом. Провайдером теперь работает сам объект контекста: запись <ThemeContext value="dark"> равнозначна <ThemeContext.Provider value="dark">, а суффикс Provider в будущих версиях обещают объявить устаревшим. Появился и API use, который читает контекст, но в отличие от useContext допускает вызов внутри условий и циклов. Забавная деталь: несмотря на имя, use формально не хук, поэтому строгие правила хуков на него не действуют. Правда есть ограничение: читать контекст через use нельзя в серверных компонентах.

import { createContext, use, useState } from "react";

const ThemeContext = createContext("light");

function ThemeLabel({ visible }) {
  if (!visible) {
    return <span>Тема скрыта настройками</span>;
  }
  const theme = use(ThemeContext);
  return <span>Активная тема: {theme}</span>;
}

export default function App() {
  const [theme, setTheme] = useState("light");
  return (
    <ThemeContext value={theme}>
      <ThemeLabel visible={true} />
      <button onClick={() => setTheme(t => (t === "light" ? "dark" : "light"))}>
        Сменить оформление
      </button>
    </ThemeContext>
  );
}

Условный вызов useContext внутри if недопустим, а use в той же позиции работает штатно: чтение темы не нужно тянуть в компонент, которому она требуется не всегда.

Контекст - инструмент доставки, а не управления состоянием, и это различие полезно держать в голове. Для темы, локали, сеанса и корзины из десятка полей связка Context с useState или useReducer закрывает задачу без внешних зависимостей и лишних килобайт в сборке. Когда перерисовки подписчиков превращаются в узкое место, честнее признать, что настало время специализированного стора. Встроенное решение прекрасно ровно до границы, за которой начинается борьба за каждый пропущенный рендер.