Контекст: данные без пробрасывания
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Подъём состояния решил задачу «двум компонентам нужно одно значение» — и создал новую: значение теперь едет вниз через этажи, которым оно не нужно. Контекст убирает эту доставку из пропсов: значение кладётся один раз наверху, а читается там, где понадобилось, хоть через десять этажей. Только это по-прежнему доставка, а не хранилище — состояние остаётся там же, где было.
В доске из части II правда лежала в одном объекте на уровне модуля, и любая функция читала её напрямую. React так не умеет: у каждого компонента своя память, и в прошлых статьях главы значение, нужное двоим, поднималось к общему родителю. Дальше оно должно как-то вернуться вниз — и единственный путь вниз — пропсы.
Пока этажей два, всё честно. Ниже тема нужна только кнопке, но объявлена в App, а между ними стоит Panel. Пример сам нажмёт «сменить тему» — предскажите, сколько строк «рендер Panel» окажется в выводе, и заодно посмотрите на тип PanelProps.
Две: одна на первую отрисовку, вторая после нажатия — Panel перерисовывается вместе с родителем. Но интереснее не вывод, а типы. В PanelProps лежит theme, которую Panel ни разу не упоминает в разметке: он курьер. Компонент, который ничего не знал о теме, теперь от неё зависит — и любое изменение Theme обязано пройти через его файл.
Такую передачу называют пробрасыванием пропсов (prop drilling). Она не ошибка сама по себе — но она заразна. Стоит вставить между панелью и кнопкой ещё один этаж, и его тоже придётся научить теме. А если забыть — компилятор скажет об этом прямым текстом:
Свойство "theme" отсутствует в типе "{ label: string; }" и является обязательным в типе "ButtonProps". Часть III приучила читать такие диагностики как подсказку: компилятор покажет каждое место, где цепочка порвалась, и до браузера ошибка не доедет. Плохо не то, что он ругается, а то, что править приходится каждый этаж по дороге — ради значения, которое нужно одному компоненту в самом низу.
Один-два этажа пропсов — норма и самый понятный код. Контекст оправдан, когда курьеров становится больше, чем получателей.
Контекст состоит из трёх частей, и все три видны в одном примере. React.createContext создаёт саму «коробку» — она объявляется вне компонентов и живёт всё время работы приложения. Разметка <ThemeContext value={…}> кладёт в коробку значение для всего поддерева. А React.use(ThemeContext) внутри компонента достаёт ближайшее значение сверху.
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, и компилятор ловит подмену на месте.
Тип "string" не может быть назначен для типа "Theme". Ошибка указывает на атрибут value — то есть на строку провайдера, а не на далёкого читателя, которому потом прилетело бы не то.
А что будет, если провайдера над компонентом нет вовсе? Ниже две одинаковые кнопки: первая внутри провайдера, вторая — соседним элементом, рядом с ним, а не под ним. Предскажите, что произойдёт со второй: ошибка при рендере, предупреждение в консоли или что-то третье?
Никакой ошибки: снаружи видит фон #eeeeee. Не найдя ни одного провайдера по дороге вверх, React берёт значение, переданное в createContext, — и делает это молча. Это и есть самая частая жалоба на контекст: «значение не приезжает». В девяти случаях из десяти читатель просто оказался выше провайдера, а вместо пустоты получил правдоподобную подделку.
«Контекст не работает» почти всегда значит «провайдер не над этим компонентом». Смотрите не на файл, а на дерево.
Лечится это типом. Если разумного значения по умолчанию не существует, честно так и напишите: null. Тип станет Theme | null, и компилятор потребует разобрать оба случая — ровно то сужение из части III, которое вы уже делали руками.
Теперь отсутствие провайдера видно и в кадре, и в выводе. Проверка стоит одну строку, зато случай «забыли обернуть» перестаёт быть невидимым.
Уберём if и обратимся к полю напрямую — компилятор напомнит про вариант с null ещё до запуска.
Возможно, "theme" имеет значение null. Диагностика приходит на строку с theme.background: контекст типизирован честно, поэтому забыть про случай без провайдера уже нельзя.
Провайдеров может быть много, и вложенный перекрывает внешний для своего поддерева — так делают тёмную врезку на светлой странице. Пример ниже считает уровень вложенности: каждая Section читает уровень и объявляет для детей уровень на единицу больше. Заголовков три, вложенность — три. Предскажите три числа в выводе.
2 3 4, а не 1 2 3. Причина в порядке: Section читает контекст до собственного провайдера и потому получает значение снаружи — своё же значение видят только дети. Компонент, который кладёт значение, сам его не видит.Задача в конце статьи проверяет именно это правило.
Отлаживать контекст удобно так: смотреть не на текст файла, а на дерево компонентов. Вопрос всегда один — какой провайдер стоит на пути от корня к этому компоненту последним. Всё, что ниже провайдера, получит его значение; всё, что рядом или выше, — нет.
Дерево, а не файл. use() ищет по цепочке родителей во время рендера — примерно как closest() из части II ищет предка в DOM.
Контекст любят считать оптимизацией: «значение больше не идёт через середину — значит, середина не перерисовывается». Половина этого правда, и проверить обе половины можно одним примером. Shell держит два состояния — тему и никак не связанный с ней счётчик тиков; Toolbar приходит в него через children и контекст не читает; Button внутри Toolbar читает. Жмём «тик» — счётчик тиков к теме отношения не имеет. Предскажите, какие строки появятся после разделителя в выводе.
После разделителя печатаются две строки — рендер Shell и рендер Button, — а Toolbar молчит. Первая половина правила подтвердилась: середина действительно ни при чём — элемент <Toolbar /> создан в App, при рендере Shell он не пересоздаётся, и React оставляет его как есть.
А вот Button перерисовался из-за тика, который к теме отношения не имеет. Виновата строка const value = { … }: объект собирается заново на каждом рендере Shell, и для React это новое значение контекста — сравнение идёт по ссылке, как в статье «Объекты и массивы в состоянии». Все читатели, сколько бы их ни было и как бы глубоко они ни лежали, получают команду перерисоваться.
Контекст не уменьшает число рендеров. Он убирает пропсы — это про читаемость, а не про скорость.
Если значение — одна из двух заранее известных тем, собирать объект в рендере незачем: две константы модуля, и ссылка меняется только при настоящей смене темы.
После «тика» печатается только рендер Shell: значение контекста осталось той же ссылкой, и читателей никто не трогал. Когда значение приходится вычислять, ссылку удерживают через useMemo — это отдельный разговор в главе про скорость.
Самое живучее заблуждение: раз значение видно отовсюду, значит, контекст — это глобальная переменная, в которую можно писать. Проверим. В примере объект настроек лежит в модуле, он же передан в провайдер, а обработчик прибавляет двойку прямо в поле. Предскажите две вещи: что напечатает console.log и что покажет кадр.
В выводе в объекте теперь 16, на экране — по-прежнему 14, а после клика не появилось ни одной новой строки «рендер Label»: перерисовки не было вовсе. Это ровно та ситуация, из-за которой в части II приходилось звать render() руками. React перерисовывает по команде setState или dispatch, а createContext ничего не хранит и ни за чем не следит: это труба, а не бак.
Значит, рабочая связка всегда одинакова: состояние живёт в компоненте наверху — useState или useReducer из прошлой статьи, — а контекст только раздаёт его вниз. Причём удобно класть в два контекста: в один значение, в другой dispatch. Функция dispatch от React стабильна и не меняется между рендерами, поэтому компоненты, которым нужно только отправлять действия, не будут дёргаться на каждое изменение данных.
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 должны исчезнуть, а на экране остаться «Привет, Рико!».
Контекст объявляется рядом с типом, значение кладёт App, читает — Greeting. Про Header задача просто забывает, и это главный признак успеха.
Значение по умолчанию — null, поэтому компилятор потребовал разобрать случай без провайдера. Обратите внимание: состояние осталось в App, контекст только доставил его вниз.
Провайдер есть, а значения нет
В App провайдер стоит на месте, значение в него передано, но console.log печатает App получил: null. При этом Toolbar тему видит. Объясните, почему так, и почините чтение в App, не меняя Toolbar.
App читает контекст среди своих предков, а его провайдер находится внутри его же разметки — то есть ниже. Компонент никогда не видит собственный провайдер, поэтому и получает значение по умолчанию.
Чинится разделением: тот, кто кладёт значение, и тот, кто читает, — разные компоненты. Достаточно вынести чтение в отдельный компонент под провайдером.
Тот же приём выносят в отдельный компонент-провайдер: он держит состояние и рендерит {children}, а всё остальное приложение живёт под ним и читает контекст свободно.
Сделать забытый провайдер заметным
Провайдера в примере нет вообще, а на экране всё равно «Привет»: значение по умолчанию выглядит как настоящие данные. Измените объявление контекста и Hello так, чтобы отсутствие провайдера стало видно — сначала компилятору, а потом и на экране. Разметку App не трогайте.
Значение по умолчанию заменяется на null, тип становится Lang | null. После этого обращение lang.code перестанет компилироваться — компилятор скажет Возможно, "lang" имеет значение null, и проверку придётся написать.
Значение по умолчанию имеет смысл только тогда, когда оно и правда разумно для всего приложения — например, светлая тема. Во всех остальных случаях null честнее: он превращает молчаливую подмену в ошибку компилятора.