Контекст: данные без пробрасывания

общее состояние≈ 13 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

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

Разделы темы:·

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

Пока этажей два, всё честно. Ниже тема нужна только кнопке, но объявлена в App, а между ними стоит Panel. Пример сам нажмёт «сменить тему» — предскажите, сколько строк «рендер Panel» окажется в выводе, и заодно посмотрите на тип PanelProps.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две: одна на первую отрисовку, вторая после нажатия — Panel перерисовывается вместе с родителем. Но интереснее не вывод, а типы. В PanelProps лежит theme, которую Panel ни разу не упоминает в разметке: он курьер. Компонент, который ничего не знал о теме, теперь от неё зависит — и любое изменение Theme обязано пройти через его файл.

Такую передачу называют пробрасыванием пропсов (prop drilling). Она не ошибка сама по себе — но она заразна. Стоит вставить между панелью и кнопкой ещё один этаж, и его тоже придётся научить теме. А если забыть — компилятор скажет об этом прямым текстом:

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Свойство "theme" отсутствует в типе "{ label: string; }" и является обязательным в типе "ButtonProps". Часть III приучила читать такие диагностики как подсказку: компилятор покажет каждое место, где цепочка порвалась, и до браузера ошибка не доедет. Плохо не то, что он ругается, а то, что править приходится каждый этаж по дороге — ради значения, которое нужно одному компоненту в самом низу.

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

Контекст состоит из трёх частей, и все три видны в одном примере. React.createContext создаёт саму «коробку» — она объявляется вне компонентов и живёт всё время работы приложения. Разметка <ThemeContext value={…}> кладёт в коробку значение для всего поддерева. А React.use(ThemeContext) внутри компонента достаёт ближайшее значение сверху.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Panel снова ничего не знает про тему: в PanelProps остался один title. При этом кнопка меняет цвет — значение доехало через этаж, который его не передавал. Пропсы идут по одной ступеньке за раз; контекст перепрыгивает всё поддерево сразу.

ЧастьГде пишетсяЧто делает
React.createContext(значение)один раз, рядом с типамисоздаёт контекст и задаёт значение по умолчанию
<Context value={…}>в разметке компонента-владельцакладёт значение для всего поддерева
React.use(Context)в теле компонента-читателяберёт ближайшее значение сверху

Про запись провайдера: с React 19 сам контекст и есть компонент, поэтому пишут <ThemeContext value={…}>. В коде постарше вы увидите <ThemeContext.Provider value={…}> — это то же самое, старая форма пока работает. Про чтение: до use() контекст читали хуком useContext(ThemeContext), и он никуда не делся. Разница в правилах: use() можно вызвать внутри if или цикла, а useContext — нет; почему хуки такие капризные, разбирает следующая глава.

Тип значения задаётся дженериком: createContext<Theme>(LIGHT). Дальше его проверяют оба конца — и провайдер, и читатель. Значение theme в Button имеет тип Theme без единой аннотации, а мимо провайдера не пройдёт ничего чужого.

Положим в провайдер строку вместо объекта — контекст создан с типом Theme, и компилятор ловит подмену на месте.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тип "string" не может быть назначен для типа "Theme". Ошибка указывает на атрибут value — то есть на строку провайдера, а не на далёкого читателя, которому потом прилетело бы не то.

А что будет, если провайдера над компонентом нет вовсе? Ниже две одинаковые кнопки: первая внутри провайдера, вторая — соседним элементом, рядом с ним, а не под ним. Предскажите, что произойдёт со второй: ошибка при рендере, предупреждение в консоли или что-то третье?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Никакой ошибки: снаружи видит фон #eeeeee. Не найдя ни одного провайдера по дороге вверх, React берёт значение, переданное в createContext, — и делает это молча. Это и есть самая частая жалоба на контекст: «значение не приезжает». В девяти случаях из десяти читатель просто оказался выше провайдера, а вместо пустоты получил правдоподобную подделку.

«Контекст не работает» почти всегда значит «провайдер не над этим компонентом». Смотрите не на файл, а на дерево.

Лечится это типом. Если разумного значения по умолчанию не существует, честно так и напишите: null. Тип станет Theme | null, и компилятор потребует разобрать оба случая — ровно то сужение из части III, которое вы уже делали руками.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Теперь отсутствие провайдера видно и в кадре, и в выводе. Проверка стоит одну строку, зато случай «забыли обернуть» перестаёт быть невидимым.

Уберём if и обратимся к полю напрямую — компилятор напомнит про вариант с null ещё до запуска.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Возможно, "theme" имеет значение null. Диагностика приходит на строку с theme.background: контекст типизирован честно, поэтому забыть про случай без провайдера уже нельзя.

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

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
2 3 4, а не 1 2 3. Причина в порядке: Section читает контекст до собственного провайдера и потому получает значение снаружи — своё же значение видят только дети. Компонент, который кладёт значение, сам его не видит.

Задача в конце статьи проверяет именно это правило.

Отлаживать контекст удобно так: смотреть не на текст файла, а на дерево компонентов. Вопрос всегда один — какой провайдер стоит на пути от корня к этому компоненту последним. Всё, что ниже провайдера, получит его значение; всё, что рядом или выше, — нет.

Дерево, а не файл. use() ищет по цепочке родителей во время рендера — примерно как closest() из части II ищет предка в DOM.

Контекст любят считать оптимизацией: «значение больше не идёт через середину — значит, середина не перерисовывается». Половина этого правда, и проверить обе половины можно одним примером. Shell держит два состояния — тему и никак не связанный с ней счётчик тиков; Toolbar приходит в него через children и контекст не читает; Button внутри Toolbar читает. Жмём «тик» — счётчик тиков к теме отношения не имеет. Предскажите, какие строки появятся после разделителя в выводе.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

После разделителя печатаются две строки — рендер Shell и рендер Button, — а Toolbar молчит. Первая половина правила подтвердилась: середина действительно ни при чём — элемент <Toolbar /> создан в App, при рендере Shell он не пересоздаётся, и React оставляет его как есть.

А вот Button перерисовался из-за тика, который к теме отношения не имеет. Виновата строка const value = { … }: объект собирается заново на каждом рендере Shell, и для React это новое значение контекста — сравнение идёт по ссылке, как в статье «Объекты и массивы в состоянии». Все читатели, сколько бы их ни было и как бы глубоко они ни лежали, получают команду перерисоваться.

Контекст не уменьшает число рендеров. Он убирает пропсы — это про читаемость, а не про скорость.

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

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

После «тика» печатается только рендер Shell: значение контекста осталось той же ссылкой, и читателей никто не трогал. Когда значение приходится вычислять, ссылку удерживают через useMemo — это отдельный разговор в главе про скорость.

Самое живучее заблуждение: раз значение видно отовсюду, значит, контекст — это глобальная переменная, в которую можно писать. Проверим. В примере объект настроек лежит в модуле, он же передан в провайдер, а обработчик прибавляет двойку прямо в поле. Предскажите две вещи: что напечатает console.log и что покажет кадр.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В выводе в объекте теперь 16, на экране — по-прежнему 14, а после клика не появилось ни одной новой строки «рендер Label»: перерисовки не было вовсе. Это ровно та ситуация, из-за которой в части II приходилось звать render() руками. React перерисовывает по команде setState или dispatch, а createContext ничего не хранит и ни за чем не следит: это труба, а не бак.

Значит, рабочая связка всегда одинакова: состояние живёт в компоненте наверху — useState или useReducer из прошлой статьи, — а контекст только раздаёт его вниз. Причём удобно класть в два контекста: в один значение, в другой dispatch. Функция dispatch от React стабильна и не меняется между рендерами, поэтому компоненты, которым нужно только отправлять действия, не будут дёргаться на каждое изменение данных.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Aisle не принимает ни одного пропса, и всё-таки счётчик считает, а кнопка добавляет. Состояние по-прежнему одно и в одном месте — как объект state из части II, только теперь его нельзя случайно поменять мимо редьюсера, и любой компонент дерева получает ровно то, что просил.

Остаётся вопрос меры. Контекст — не замена пропсам, а инструмент для значений, которые нужны широко и меняются редко: тема, язык, текущий пользователь, содержимое корзины. Заворачивать в контекст данные одного экрана — верный способ получить приложение, где ничего не понять без поиска по всему проекту.

СитуацияЧем передавать
Значение нужно соседу или через один этажпропсами — это самый читаемый код
Тема, язык, текущий пользователь — нужны половине дереваконтекстом
Обёртке не важно, что внутрипропсом children, контекст не нужен
Значение меняется на каждый символ ввода и нужно многимконтекстом, но отдельным от редко меняющегося

Отдельный контекст для часто меняющегося значения выручает, пока таких значений немного. Когда независимых кусков много и они лежат в одном объекте, перемена любого из них перерисовывает всех читателей, а не только тех, кому этот кусок нужен. Тут контекста уже мало: нужно хранилище, на которое подписываются по кусочку состояния. Где проходит эта граница и что берут за ней, разобрано на полке React и Next.

  • Контекст избавляет от пробрасывания: значение кладут провайдером наверху, а use(Context) берёт ближайшее значение сверху — этажи между ними о нём не знают.
  • Если провайдера над компонентом нет, React молча подставляет значение из createContext; тип T | null превращает эту ловушку в диагностику компилятора.
  • Провайдеры вкладываются, побеждает ближайший сверху; собственный провайдер компонент не видит — только его дети.
  • Контекст не сокращает рендеры: новый объект в value перерисовывает всех читателей, даже если изменилось не относящееся к делу состояние.
  • Контекст ничего не хранит — мутация значения экран не обновит. Состояние остаётся в useState или useReducer выше провайдера, а контекст только доставляет его вниз.

1/4Контекст создан как createContext<Theme>(LIGHT). Компонент, читающий его через use(), оказался выше провайдера в дереве. Что произойдёт?

2/4Компонент Section читает use(LevelContext) и тут же рендерит <LevelContext value={level + 1}> со своими детьми. Какое значение увидит сам Section?

3/4В компоненте-провайдере есть состояние, к теме не относящееся, а значение записано как value={{ background, color }}. Нажали кнопку, меняющую это постороннее состояние. Что произойдёт с компонентами, читающими контекст?

4/4Объект лежит в модуле и передан в провайдер. Обработчик клика меняет его поле напрямую: settings.fontSize += 2. Что увидит пользователь?

Убрать курьера

Header принимает user только чтобы передать его в Greeting. Перепишите пример на контекст: HeaderProps и GreetingProps должны исчезнуть, а на экране остаться «Привет, Рико!».

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Контекст объявляется рядом с типом, значение кладёт App, читает — Greeting. Про Header задача просто забывает, и это главный признак успеха.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Значение по умолчанию — null, поэтому компилятор потребовал разобрать случай без провайдера. Обратите внимание: состояние осталось в App, контекст только доставил его вниз.

Провайдер есть, а значения нет

В App провайдер стоит на месте, значение в него передано, но console.log печатает App получил: null. При этом Toolbar тему видит. Объясните, почему так, и почините чтение в App, не меняя Toolbar.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Чинится разделением: тот, кто кладёт значение, и тот, кто читает, — разные компоненты. Достаточно вынести чтение в отдельный компонент под провайдером.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тот же приём выносят в отдельный компонент-провайдер: он держит состояние и рендерит {children}, а всё остальное приложение живёт под ним и читает контекст свободно.

Сделать забытый провайдер заметным

Провайдера в примере нет вообще, а на экране всё равно «Привет»: значение по умолчанию выглядит как настоящие данные. Измените объявление контекста и Hello так, чтобы отсутствие провайдера стало видно — сначала компилятору, а потом и на экране. Разметку App не трогайте.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Значение по умолчанию заменяется на null, тип становится Lang | null. После этого обращение lang.code перестанет компилироваться — компилятор скажет Возможно, "lang" имеет значение null, и проверку придётся написать.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Значение по умолчанию имеет смысл только тогда, когда оно и правда разумно для всего приложения — например, светлая тема. Во всех остальных случаях null честнее: он превращает молчаливую подмену в ошибку компилятора.