Подъём состояния
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Доска из части II держала правду в одном объекте, и render() рисовал из него весь экран. React делит память: у каждого компонента свой useState, и стоит двум из них понадобиться одно значение, как встаёт вопрос — чьё оно. Ответ короткий: состояние поднимают к ближайшему общему родителю, вниз оно течёт пропсами, вверх — функциями. А две копии одного значения не синхронизируют: от одной избавляются.
В доске из части II правда лежала в одном объекте state на уровне модуля, и render() рисовал из него всё. React режет экран на компоненты, и у каждого своя память — свой useState. Пока значение нужно одному компоненту, вопросов нет. Но вот шапка магазина показывает, сколько товаров в корзине, а кнопка «добавить» стоит в списке товаров — в другом компоненте. Рука из части II тянется к знакомому: завести переменную модуля и читать её из обоих мест. Предскажите, что покажет шапка после нажатия «добавить» и что — после нажатия «меню».
Переменная изменилась, а шапка — нет: у React не было повода её перерисовать. Из статьи «Триггер, рендер, коммит» вы помните: заявку на отрисовку подаёт только сеттер состояния, а inCart += 1 — не сеттер, это присваивание, о котором React не узнаёт. Зато клик по «меню» вызвал setMenuOpen, Header перерисовался, заново прочитал переменную — и показал единицу. Экран догнал данные случайно, по чужому поводу.
Это худший вид поломки: не «не работает», а «работает иногда». В части II вас страховал ручной вызов render() после каждого изменения — вы держали его в руках. В React отрисовкой распоряжается он сам, и повод у него один: изменилось состояние, которое он хранит. Всё, что живёт снаружи useState, для React невидимо — переменная модуля, поле объекта, свойство window.
Состояние — это то, что лежит в useState. Только его изменение вызывает отрисовку; переменная модуля — не состояние, даже если в ней «правда».
Раз значение обязано лежать в useState, а память у каждого компонента своя, остаётся вопрос: в чьём? Ответ React: у того, кто видит обоих, — у ближайшего общего родителя. App возвращает и Header, и Products, значит, inCart переезжает в App. Вниз в шапку число приезжает пропсом, как в статье «Пропсы и их типы». Списку товаров нужно не число, а способ его изменить — и это тоже пропс, только с функцией внутри.
Теперь у корзины ровно одно место хранения, и оба компонента смотрят на него: шапка через пропс count, список через onAdd. Клик в Products вызывает функцию, созданную в App; та вызывает setInCart — сеттер состояния App; App перерисовывается и, по правилу из статьи про рендер и коммит, перерисовывает всё, что возвращает, — в том числе шапку с новым числом.
Данные текут вниз пропсами, события — вверх функциями. Схема повторяет делегирование из части II: там клик всплывал к контейнеру, а data-action подсказывал, что делать. Разница в том, что провод теперь видит компилятор — с обоих концов. Уберите onAdd из разметки App и посмотрите, что он скажет.
«Свойство onAdd отсутствует в типе {} и является обязательным в типе ProductsProps» — компилятор нашёл оборванный провод до запуска. В части II опечатка в data-action молчала до первого клика, и то если вы догадались нажать. Кнопка здесь тоже молчит — код после диагностики выполняется, как и в tsc, — но диагностика уже на экране, а не в отчёте пользователя.
Имя onAdd — соглашение, а не требование: пропс-функцию называют как событие — onSelect, onChange, onClose. Родитель читает разметку как список того, о чём ему сообщат.
Подъём нужен не только когда два компонента показывают одно число. Чаще значение связывает соседей правилом: из двух панелей открыта не больше одной, в списке выбрана одна строка, среди вкладок активна одна. Панель ниже честно хранит свою открытость у себя. Предскажите, что покажут обе панели после двух кликов.
Открыты обе — и ни одна панель не могла поступить иначе. Чтобы закрыться при открытии соседки, панели нужно о соседке знать, а из статьи про рендер и коммит вы помните: состояние идёт вниз, но не вбок. У панели нет ни сеттера соседки, ни даже её имени. Правило «не больше одной» — не свойство панели, это свойство набора. Значит, и хранить его должен тот, кто набором владеет.
У родителя вместо двух булевых одно состояние — id открытой панели или null. Панель перестаёт помнить и начинает получать: открыта она или нет — пропс, «меня нажали» — функция. Ни одна панель не решает про себя; решает Faq, и его решение по построению не может нарушить правило.
Присмотритесь к форме состояния: не два isOpen, а один activeId. Это правило из статьи «Как выбирать структуру состояния» — противоречивые сочетания должны быть невыразимы, — только применённое сразу к нескольким компонентам. Два флага допускали «обе открыты»; строка допускает ровно одну. Свой isOpen каждая панель получает уже вычисленным: activeId === "delivery".
Подъём меняет не только адрес состояния, но и его форму: два флага в двух компонентах у родителя становятся одним значением.
Сейчас открытую панель нельзя закрыть: onShow умеет только открывать. Чтобы второй клик закрывал, менять нужно не панель, а родителя — решение по-прежнему у него: setActiveId(activeId === id ? null : id), а пропс честнее назвать onToggle. Панель об этой правке не узнает: она как сообщала «меня нажали», так и сообщает. В этом и удобство управляемого компонента — вся логика набора в одном месте, и меняется она в одном месте.
Самый живучий компромисс звучит разумно: «пусть у ребёнка будет своя копия для отзывчивости, а родителю он будет сообщать». Счётчик количества ниже так и устроен: хранит value у себя и на каждый шаг зовёт onChange. У родителя есть кнопка «очистить». Жмём «+» дважды, «очистить», снова «+». Предскажите обе строки вывода.
После «очистить» корзина показывает ноль, а кнопка — двойку: setCount(0) изменил состояние Cart, а до копии в Stepper не дотянулся — useState(initial) читает аргумент один раз, как вы видели в статье о структуре состояния. Дальше хуже: следующий шаг считается от копии, и родитель получает тройку. Пользователь видел ноль, нажал плюс один раз — и получил три.
Здесь нет забытой строки, которую можно дописать. У каждой копии свой набор обработчиков: step меняет обе, «очистить» — одну. Любой обработчик, который трогает одну копию и не трогает другую, разводит их, и таких обработчиков со временем становится больше, а не меньше. «Досинхронизировать» эффектом — useEffect(() => setValue(initial), [initial]) — вы уже пробовали в статье «Когда эффект не нужен»: копия догоняет с опозданием на кадр, и этот кадр виден. Две копии одного значения не синхронизируют — от одной избавляются.
У Stepper не осталось состояния — и не осталось способа разойтись с родителем. Компонент либо владеет значением, либо получает его; третьего нет. Компонент со своим useState называют неуправляемым: снаружи ему можно дать только стартовое значение — initial, defaultValue, — дальше он живёт сам. Компонент, который получает значение и функцию, — управляемым: он ничего не решает, только показывает и сообщает. Это те же слова, что у полей ввода в главе о событиях и формах, и смысл у них тот же.
| Неуправляемый | Управляемый | |
|---|---|---|
| Где значение | в своём useState | в состоянии родителя |
| Что получает | initialValue — только старт | value и onChange |
| Кто меняет | только сам компонент | родитель — сеттером; компонент лишь просит через onChange |
| Когда уместен | значение никому снаружи не нужно | значение показывают или меняют ещё где-то |
| Как назвать пропс | initialCount, defaultOpen | count, isOpen |
Имя пропса — обещание. initialValue говорит «дальше обновлений не жди», value — «я всегда актуален». Пропс не может быть и тем и другим.
Подъём — не «всё состояние в App». Поднимают до ближайшего компонента, который видит всех, кому значение нужно, и ни на шаг выше. Ниже menuOpen положили в корень «на всякий случай», хотя читает его только шапка. Предскажите, сколько раз напечатается «отрисовка Products» после двух кликов по меню.
Три раза: первая отрисовка и по одной на каждый клик. Меню не имеет к списку товаров никакого отношения, но состояние лежит в App, а по правилу из статьи про рендер и коммит перерисовывается компонент, чьё состояние изменилось, и всё, что он возвращает. Перенесите useState с меню внутрь Header и запустите снова: строка напечатается один раз. Для трёх компонентов разница незаметна, для экрана из сотни — уже нет; о цене отрисовок подробно в главе про скорость.
И не всякое состояние вообще нужно поднимать. Раскрыт ли выпадающий список, подсвечена ли строка под курсором, что набрано в поле поиска до нажатия «найти» — это никому, кроме самого компонента, не интересно, и такому состоянию место в нём. Признак, что пора поднимать, ровно один: значение читает или меняет кто-то ещё.
| Вопрос | Да | Нет |
|---|---|---|
| Значение показывает или меняет другой компонент? | поднять к ближайшему общему родителю | оставить у себя |
| Есть правило, связывающее соседей: одна открыта, одна выбрана? | одно состояние у владельца набора | у каждого своя память |
| Компоненту нужен только старт, дальше он живёт сам? | пропс initialX и свой useState | пропсы value и onChange |
У подъёма есть цена: значение приходится передавать через промежуточные компоненты, которым оно не нужно, — это называют пробрасыванием. Пока уровня два-три, оно даже полезно: провод виден целиком. Когда пропс проходит через пять компонентов, которые его только передают дальше, есть контекст — ему посвящена последняя статья главы. Но и там правило то же: состояние живёт в одном месте, контекст лишь меняет способ доставки.
- Отрисовку вызывает только сеттер из
useState: переменная модуля и любая «правда» вне состояния для React невидимы, и экран догонит её лишь случайно. - Когда значение нужно двум компонентам, его хранит ближайший общий родитель: вниз оно идёт пропсом, вверх — пропсом-функцией вроде
onAdd. - Правило, связывающее соседей («открыта не больше одной»), не может соблюсти ни один из них: состояние набора хранит владелец набора — одним значением вместо нескольких флагов.
- Две копии одного значения не синхронизируют: у каждой свои обработчики, и они разойдутся. Компонент либо владеет значением (свой
useState, пропсinitialX), либо получает его (valueиonChange). - Поднимают до ближайшего общего родителя и ни на шаг выше: состояние в корне перерисовывает весь экран, а то, что нужно одному компоненту, остаётся в нём.
- Компилятор видит оба конца провода: забытый пропс-функция — диагностика до запуска, а не молчащая кнопка, как с
data-actionв части II.
1/4Компонент Products по клику делает inCart += 1 с переменной модуля, а Header читает её в разметке. Что покажет Header сразу после клика?
2/4Две панели Panel хранят открытость каждая в своём useState. Нужно правило «открыта не больше одной». Где ему место?
3/4Stepper хранит копию value в useState(initial) и на шаг зовёт onChange(value + 1). Родитель сбросил своё count в ноль кнопкой. Что покажет Stepper?
4/4Значение menuOpen читает и меняет только Header. Куда его положить?
Поиск и список
Строка поиска хранит запрос у себя, а список товаров показывает всё подряд: запроса у него нет. Поднимите состояние так, чтобы после нажатия «искать «ка»» список показывал только подходящие товары. Чтобы не связываться с полем ввода, запрос задаётся кнопкой.
Запрос нужен двум компонентам — значит, живёт у их общего родителя Shop. SearchBox становится управляемым: получает query и onChange. List получает query и фильтрует прямо в теле — результат поиска вычисляется, а не хранится, по правилу из статьи о структуре состояния.
После правки вывод — «какао, капучино». Заведи вы в List своё состояние с результатом, оно разошлось бы с запросом при первом же изменении списка товаров.
Одна выбранная строка
Каждая строка списка помнит, выбрана ли она, поэтому после двух кликов выбраны обе. Нужно, чтобы выбранной оставалась только последняя нажатая. Почините, не добавляя эффектов.
«Выбрана одна» — правило набора, и ни одна строка не может его соблюсти: о соседках она не знает. Состояние поднимается в Picker и меняет форму: вместо флага в каждой строке — один selectedId. Строка получает selected уже вычисленным и сообщает о клике через onSelect.
Вывод — «[ ] чай[x] кофе». Заодно исчезла целая семья будущих задач: «снять выделение», «выбрать следующую», «есть ли выбранная» — всё это один selectedId в одном месте.
Опустить обратно
Подсказка у иконки помощи нужна только самой иконке, но её состояние лежит в App, и каждый клик по ней перерисовывает тяжёлый список. Переместите состояние так, чтобы «отрисовка списка» напечаталась один раз, а подсказка продолжала работать.
Подсказку никто, кроме иконки, не читает и не меняет — поднимать её было не за чем. Состояние опускается внутрь HelpIcon, пропсы исчезают, App перестаёт перерисовываться от кликов по иконке — а с ним и список.
Правило симметричное: поднимают, когда значение нужно кому-то ещё, и опускают, когда перестало быть нужным. Если позже подсказку понадобится закрывать из другого места — тогда и поднимете.