Локальное состояние против глобального и цена Context

Вопрос, отделяющий «читал документацию» от «делал приложение»: почему Context не заменяет state manager.

Context — способ доставить значение вниз по дереву, минуя props. Это транспорт, а не хранилище: у него нет ни точечных подписок, ни встроенной оптимизации обновлений.

Вопрос

«Как решаете, где хранить состояние? Почему нельзя просто положить всё в Context вместо Redux?»

Что на самом деле проверяет интервьюер

Есть ли у вас критерии выбора. Плохой ответ — «беру Redux, потому что так принято» или «Context проще, зачем библиотеки». Хороший — разложить состояние на виды и объяснить, что и почему живёт локально, а что глобально.

Развёрнутый ответ

Четыре вида состояния

  1. Локальное UI-состояние: открыт ли аккордеон, введённый текст, выбранная вкладка. Живёт в useState внутри компонента. Выносить его наверх не нужно.
  2. Разделяемое состояние: текущий пользователь, тема, корзина, язык. Нужно многим экранам — вот здесь появляется контекст или store.
  3. Серверное состояние: данные из API. У него свои проблемы — кэш, устаревание, повторные запросы, — поэтому его обычно отдают TanStack Query или RTK Query, а не хранят руками.
  4. Состояние навигации: где находится пользователь. Этим управляет React Navigation, дублировать его не нужно.

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

Цена Context

Все компоненты, вызывающие useContext, перерисовываются при любом изменении значения контекста — независимо от того, какую часть данных они используют. Сравнение идёт по ссылке.

// ПЛОХО: новый объект на каждом рендере провайдера
function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

Даже если user не изменился, любой рендер провайдера (например, из-за другого состояния рядом) создаст новый объект и разбудит всех потребителей. Первое лекарство — useMemo:

const value = useMemo(() => ({ user, login, logout }), [user, login, logout]);

Второе, более сильное, — разделить контексты на «редко меняющиеся действия» и «часто меняющиеся данные»:

const AuthActionsContext = createContext(null); // login, logout — стабильны
const AuthStateContext = createContext(null);   // user — меняется

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);

  const actions = useMemo(() => ({
    login: async (creds) => setUser(await api.login(creds)),
    logout: () => setUser(null),
  }), []);

  return (
    <AuthActionsContext.Provider value={actions}>
      <AuthStateContext.Provider value={user}>{children}</AuthStateContext.Provider>
    </AuthActionsContext.Provider>
  );
}

Теперь кнопка «Выйти», которой нужны только действия, не перерисовывается при обновлении профиля.

Почему на мобильном это важнее, чем в вебе

Лавина рендеров в браузере на десктопе часто незаметна. На бюджетном Android она даёт пропущенные кадры при скролле и ощутимую задержку отклика. Кроме того, JS-поток в RN один: лишняя работа в нём напрямую отнимает время у обработки жестов.

Когда Context достаточен

  • Тема оформления, локаль, флаги фич — меняются редко.
  • Небольшое приложение с одним-двумя разделяемыми значениями.
  • Инъекция зависимостей: клиент API, конфигурация, аналитика.

Когда пора брать store: много несвязанных частей состояния, нужны точечные подписки на отдельные поля, требуется middleware (логирование, persist, offline-очередь), важна отладка через инструменты и предсказуемая история изменений.

Типичные ошибки кандидатов

  • Говорят «Context — это встроенный Redux». Разные задачи: Redux хранит и позволяет подписаться на срез, Context только доставляет значение.
  • Складывают в один провайдер всё приложение и получают перерисовку всего дерева на каждый чих.
  • Держат серверные данные в глобальном store вручную, изобретая кэш и инвалидацию, вместо готового решения.
  • Поднимают наверх локальное состояние формы — потом каждый символ в поле ввода перерисовывает половину экрана.

Как ответить кратко

«Разделяю состояние на локальное UI, разделяемое клиентское, серверное и состояние навигации. Локальное держу в useState рядом с компонентом, серверное отдаю TanStack Query или RTK Query — там кэш и инвалидация из коробки. Context — это транспорт, а не хранилище: он доставляет значение вниз, но перерисовывает всех потребителей при любом изменении значения, потому что сравнение идёт по ссылке. Поэтому значение мемоизирую и разделяю на контекст действий и контекст данных. Когда частей состояния много и нужны точечные подписки, middleware и отладка — беру полноценный store.»

Проверьте себя
1. Что произойдёт, если значение провайдера Context собрать литералом объекта прямо в рендере?
AНичего: React сравнит содержимое объекта и лишних рендеров не будет
BНа каждом рендере провайдера создастся новая ссылка, и перерисуются все потребители контекста
CReact выбросит предупреждение и проигнорирует обновление
DКонтекст перестанет работать в production-сборке
2. Почему Context называют механизмом доставки, а не менеджером состояния?
AПотому что он не хранит данные сам — он лишь передаёт значение вниз по дереву без проброса через props
BПотому что он работает только с примитивами
CПотому что он не работает в React Native
DПотому что он всегда требует useReducer
3. Как уменьшить количество перерисовок от контекста с данными и функциями?
AОбернуть провайдер в React.memo
BРазделить на два контекста: редко меняющиеся действия и часто меняющиеся данные
CПеренести контекст выше по дереву
DИспользовать вместо useContext обычный импорт