Когда состояние сохраняется, а когда сбрасывается
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В статье про useState два одинаковых счётчика считали порознь, а в квизе к ней прозвучал ответ, который пора сделать главным правилом: состояние принадлежит месту компонента в дереве, а не его коду. Из этого правила выводится всё остальное — почему счётчик переживает смену ветки в условии, почему сбрасывается от одной лишней обёртки и как заставить его начать заново одним атрибутом key.
В статье «Зачем React, если вы уже писали рендер из состояния» выяснилось, что DOM-узел переживает перерисовку, пока на его месте в описании стоит элемент того же типа, а своя сверка добавила, откуда React знает, какой элемент старый: по месту среди соседей и по типу, а не по id. Со состоянием React поступает так же, и первое следствие выглядит странно. Ниже <Counter /> написан дважды, в двух ветках тернарного оператора. Нажимаем на счётчик два раза и переключаем ветку: что напечатает четвёртая строка — count = 2 или count = 0?
count = 2: счёт уцелел, поменялся только fancy. React не читает ваш тернарный оператор и не знает, что тегов два. Он получает от App готовое описание, и в обоих случаях оно одинаковое: div, в нём первым ребёнком — Counter, вторым — кнопка. Тот же тип на том же месте — значит, тот же компонент, и его ячейка состояния остаётся на месте; из нового описания React берёт лишь свежие пропсы.Сколько раз тег написан в коде, не важно. Важно, что окажется на этом месте в описании после рендера.
Отсюда способ рассуждать, который пригодится во всей статье: не «тот ли это компонент, что я написал», а «тот ли это адрес в дереве и тот ли тип по этому адресу». Адрес — это путь от корня: App → div → первый ребёнок. Пока путь и тип совпадают от отрисовки к отрисовке, состояние живёт. Дальше — три способа этот путь сломать, и один из них вам понадобится нарочно.
Пусть теперь элемент вообще один: описание <Counter /> создаётся один раз и кладётся в переменную, а ветки условия отличаются только обёрткой — <fieldset> или <div>. Объект тот же самый, тип Counter тот же самый. Переживёт ли счёт переключение?
count = 0. Изменился не Counter, а его адрес: было div → div → Counter, стало div → fieldset → Counter. Сверка идёт сверху вниз, и на уровне обёртки React видит другой тип. Он не пытается заглянуть глубже и поискать знакомый Counter внутри: поддерево на этом месте сносится целиком — DOM-узлы, состояние всех компонентов внутри, их эффекты — и строится заново. Это тот самый innerHTML из части II, только локальный: разрушение ограничено одной веткой.То же происходит, когда компонент просто исчезает из описания: {shown && <Counter />} при shown === false оставляет на месте счётчика пустоту, и React уничтожает его вместе с состоянием. Когда shown снова станет true, на этом месте появится новый Counter, а useState(0) честно начнёт с нуля. React не откладывает состояние ушедшего компонента «на потом» — его больше нет.
Сброс — это не «обнулить count». Это снести поддерево и построить новое: с новым состоянием, новыми узлами и новыми ref.
Обратите внимание, что переменная counter не помогла. Описание — это просто объект с полем type, как показано в статье «JSX — это не HTML»; в нём нет ни ссылки на состояние, ни памяти о прошлой отрисовке. Где именно этот объект окажется в дереве, решается только в момент сверки.
Адрес компонента включает и номер среди соседей. Значит, если первый ребёнок исчезнет, второй должен сдвинуться на его место и потерять своё состояние? Проверим: нажимаем first один раз, second — три, скрываем first, потом возвращаем. Что покажет second после скрытия и с каким счётом вернётся first?
second остался с тройкой, а first вернулся с нулём. Сдвига не произошло, потому что {showFirst && ...} при ложном условии не убирает ребёнка из описания, а оставляет на его месте false. Для React это «пустое место»: узла не нужно, но позиция занята, и second по-прежнему второй. Вот почему условия в JSX безопасны для соседей — null, false и undefined держат слот.
Слот перестаёт держаться, когда дети приходят из массива и массив меняет длину. Те же два счётчика, но теперь их порождает map по списку подписей — а список при скрытии становится короче. Что напечатает second после нажатия «скрыть first»?
first со счётом 1. Тип совпал, ключей нет, и React считает это тем же компонентом: ячейка со счётом остаётся, а подпись меняется на «second». Компонент под индексом 1 — настоящий second с тройкой — оказался лишним и уничтожен. Состояние не потерялось, оно досталось не тому.Это та самая история про key в списках из статьи «Выражения, условия и списки в разметке», увиденная со стороны состояния. Ключ заменяет адрес по индексу адресом по имени: React ищет среди старых соседей элемент с тем же ключом, а не с тем же порядковым номером.
Теперь «second: 3» — и никакого рендера для first: React нашёл старого second по ключу, а first, которого в новом списке нет, снёс. В статье о списках ключ был нужен, чтобы React узнавал строки; со стороны состояния то же правило звучит строже — чтобы состояние одной строки не досталось другой.
Условие в фигурных скобках держит слот. Массив переменной длины без ключей — не держит.
В статье «Когда эффект не нужен» осталось обещание: сбрасывать состояние при смене пропса — задача не для useEffect, а для ключа. Пора его выполнить. Компонент Rating хранит оценку товара в своём состоянии; в App есть кнопка «следующий товар». Ставим чайнику три звезды и переходим к тостеру. Какую оценку увидит тостер?
Тостер начинает с тремя звёздами. По правилу из первого раздела иначе и быть не могло: адрес App → div → Rating не изменился, тип не изменился, значит, это тот же компонент с тем же stars, а product — просто новый проп. Для React смена товара ничем не отличается от смены fancy. Но для нас это другой товар, и оценка должна быть его собственной.
Ключ есть у любого элемента, не только у строки списка. Он часть адреса: два элемента одного типа на одном месте, но с разными ключами — это разные компоненты. Добавляем key={product.id} и проходим тот же путь, а потом возвращаемся к чайнику. Сколько звёзд у него окажется?
У тостера ноль — и у чайника при возвращении тоже ноль. Первое ожидаемо: другой ключ — другой компонент, старый Rating снесён вместе с тремя звёздами, новый начал с useState(0). Второе важно не пропустить: смена ключа — это снос и постройка, а не переключатель. React не хранит состояние ключа 1, пока показывает ключ 2; возвращение к чайнику — снова новый компонент. Если оценки всех товаров нужно помнить, им место не внутри Rating, а выше — в состоянии, которое поднято к родителю или хранится списком; это тема предыдущей статьи, «Подъём состояния».
Смена ключа не кладёт старое состояние на полку. Она его выбрасывает.
Соблазн написать useEffect(() => setStars(0), [product.id]) велик, и статья «Когда эффект не нужен» уже объяснила, чем он плох: сначала React закоммитит кадр с чужими тремя звёздами, потом эффект подаст заявку на сброс, потом будет ещё одна отрисовка. Читатель увидит мигание, а вы — лишний рендер.
Ключ делает это в одну отрисовку и заодно сбрасывает всё, что эффект не достанет: состояние вложенных компонентов, значения неуправляемых полей, ref, подписки. Правило простое: если компонент показывает «другую сущность», дайте ему ключ этой сущности.
Последний способ сломать адрес самый коварный, потому что в разметке ничего не меняется. Тип элемента — это функция, и React сравнивает типы через ===. Что случится, если объявить Counter внутри App? Три нажатия на счётчик, затем переключаем тему.
Пока вы нажимали на счётчик, всё работало: перерисовывался только Counter, App не вызывался. Но клик по теме перерисовал App, и тело функции выполнилось заново — включая строку function Counter(). Это новая функция, не равная прошлой по ===. На адресе App → div → первый ребёнок оказался другой тип, и React снёс поддерево: счёт обнулился, а будь внутри поле ввода — оно потеряло бы фокус и текст на каждом нажатии клавиши в родителе.
Симптом в живом проекте именно такой: «поле теряет фокус при вводе», «раскрытый список схлопывается сам». Причина почти всегда одна — компонент объявлен внутри другого компонента, чтобы дотянуться до его переменных. Лечение: объявлять компоненты на верхнем уровне модуля, а нужные данные передавать пропсами. Замыкание на переменные родителя — не повод создавать функцию заново при каждом его рендере.
Компонент внутри компонента ломается не сразу, а при первой перерисовке родителя. Поэтому баг находят поздно.
Вопрос перед каждым условием в разметке один и тот же: сохранится ли путь от корня до компонента — с теми же типами на каждом шаге и тем же ключом (или тем же индексом) среди соседей? Если да, состояние остаётся; если нет — на этом месте новый компонент.
| Что изменилось | Состояние | Почему |
|---|---|---|
| Пропсы у того же типа на том же месте | сохраняется | React обновляет существующий компонент, ячейка на месте |
Компонент ушёл из описания (null, false) и вернулся | сброшено | поддерево снесено при уходе, при возврате строится новое |
| Другой тип на том же месте: обёртка, тег, другая функция | сброшено вместе с потомками | сверка не ищет совпадений глубже несовпавшего типа |
| Соседи из массива сдвинулись, ключей нет | осталось у индекса, а не у данных | без ключа адрес — порядковый номер |
Изменился key | сброшено | другой ключ — другой компонент, старый выброшен |
| Родитель перерисовался, а компонент объявлен внутри него | сброшено | новая функция не равна старой по === |
Две строки таблицы вы будете использовать нарочно. key — чтобы «другая сущность» получила чистое состояние. А если состояние должно пережить уход компонента из дерева — вкладку, шаг мастера, свёрнутую панель, — его нужно хранить там, где оно переживёт: у родителя, который остаётся на месте. Это подъём состояния из предыдущей статьи; следующая, про useReducer, ответит, как таким поднятым состоянием управлять, когда переходов в нём становится много.
Спрашивайте не «тот ли это компонент», а «тот ли это адрес». Ответ на второй вопрос и есть ответ на первый.
- Состояние привязано к адресу компонента в дереве — пути от корня с типами на каждом шаге и ключом или индексом среди соседей, — а не к тегу в коде и не к объекту описания.
- Тот же тип на том же месте сохраняет состояние, даже если тег написан в разных ветках условия; изменился только проп.
- Другой тип на любом уровне пути — обёртка, тег, другая функция — сносит всё поддерево вместе с состоянием, как и уход компонента из описания.
- Условие в фигурных скобках оставляет false и держит слот для соседей; массив переменной длины без ключей сдвигает соседей, и состояние достаётся чужим данным.
- key — часть адреса: новый ключ создаёт новый компонент с чистым состоянием, а старое выбрасывается, а не откладывается.
- Компонент, объявленный внутри другого, — новая функция при каждом рендере родителя; объявляйте компоненты на верхнем уровне модуля.
1/4В App написано {big ? <Counter size="big" /> : <Counter size="small" />}. Нажали на счётчик пять раз и переключили big. Что покажет счётчик?
2/4Разметка изменена на {big ? <section><Counter /></section> : <p><Counter /></p>}. Тот же сценарий: пять нажатий и переключение big.
3/4<Profile key={userId} /> хранит в состоянии раскрытые секции. Пользователь раскрыл две секции у userId = 1, перешёл к userId = 2, а потом вернулся к 1. Что он увидит?
4/4В App подряд стоят {showA && <Counter label="A" />} и <Counter label="B" />. Нажали B три раза и скрыли A. Что покажет B?
Счёт для каждого игрока
Компонент Score считает очки игрока и хранит их в своём состоянии. При переходе к следующему игроку очки должны начинаться с нуля, а сейчас Борис получает очки Ани. Исправьте App, не меняя тело Score и не добавляя эффектов.
Для React смена игрока — просто новый проп у того же компонента по тому же адресу. Чтобы это был другой компонент, в адрес нужно добавить ключ игрока:
При смене player.id старый Score уничтожается вместе с очками, а новый начинает с useState(0). Помните, что возвращение к Ане тоже создаст новый компонент: если очки каждого игрока нужно помнить, их место в состоянии App, а не внутри Score.
Счётчик, который боится кнопки «размер»
Счётчик работает, пока не нажать «размер»: после этого он обнуляется, хотя в разметке для него ничего не изменилось. Найдите причину и исправьте, не меняя логику ни одного из компонентов.
Counter объявлен внутри App, поэтому каждый рендер App создаёт новую функцию. React сравнивает типы через ===: на прежнем месте оказывается «другой» компонент, и поддерево строится заново. Пока перерисовывался только сам счётчик, баг не проявлялся — App не вызывался.
Лечение — вынести объявление на верхний уровень модуля:
Теперь функция одна на весь модуль, тип от рендера к рендеру совпадает, и состояние живёт. Если бы Counter зависел от переменных App, их нужно было бы передать пропсами.
Две вкладки — два счёта
На двух вкладках стоит по счётчику, но нажатия на одной вкладке видны на другой: после переключения туда-обратно «left» показывает 3 вместо 2. Сделайте счётчики независимыми и объясните, при каком решении счёт вкладки переживёт переключение, а при каком — нет.
Оба Counter стоят на одном адресе, поэтому React считает их одним компонентом. Самое короткое исправление — разные ключи:
Теперь это два разных компонента, и нажатие на «right» до «left» не доходит. Но каждый из них живёт, только пока показана его вкладка: переключение уничтожает старый счётчик и создаёт новый, так что «left» вернётся с нулём, а не с двойкой.
Чтобы счёт переживал переключение, компонент должен оставаться в дереве. Можно держать в описании оба и прятать неактивный атрибутом hidden:
Адреса обоих счётчиков теперь постоянны, меняется только видимость узла. А если вкладок много или они тяжёлые, счёт поднимают в состояние App и передают вниз пропсами — так поступает статья о подъёме состояния.