Как выбирать структуру состояния
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В проекте части II состояние доски помещалось в один массив задач, а число готовых и отфильтрованный список render() считал заново при каждом вызове. Стоило завести для них отдельные переменные — и они начинали расходиться с массивом. В React соблазн тот же, только вместо переменных — лишние useState, и расходятся они точно так же. Правило одно: в состоянии лежит только то, что нельзя вычислить из остального.
Список товаров и поиск по нему. Результат поиска кажется полноправным состоянием: он меняется, он показывается на экране, у него есть сеттер. Пример добавляет в список «каркаде» — товар, который подходит под текущий запрос «ка». Предскажите, появится ли он в абзаце.
Каркаде попал в items, а абзац не изменился: found никто не пересчитал. Это не оплошность в одном обработчике, а свойство конструкции. У результата поиска два источника — список и запрос, — и каждый обработчик, который трогает любой из них, обязан помнить про третье состояние. В части II так же расходились массив задач и счётчик готовых, пока подсчёт не переехал внутрь render().
Лечится точно так же: результат считается в теле компонента. Тело выполняется на каждой отрисовке, а отрисовку вызывает любое изменение состояния, — значит, пересчёт нельзя забыть, он происходит сам.
Состояний два вместо трёх, обработчики уместились в разметку, а расходиться больше нечему. Отсюда главное правило статьи: в состоянии лежит только то, что нельзя вычислить из остального состояния и пропсов. Всё производное — обычные константы в теле компонента.
Вопрос «не дорого ли фильтровать на каждой отрисовке» законный, но преждевременный: для сотни строк это незаметно. Когда станет заметно, есть useMemo — глава про скорость. Сперва правильная структура, потом ускорение.
Список кандидатов на выселение из состояния у всех проектов примерно один.
| Хочется положить в состояние | Из чего считается | Что писать в теле компонента |
|---|---|---|
fullName | firstName и lastName | firstName + " " + lastName |
doneCount | массив задач | tasks.filter((t) => t.done).length |
isEmpty | массив | items.length === 0 |
total корзины | список позиций | reduce по позициям |
canSubmit | поля формы | name !== "" && agreed |
Особый случай лишнего состояния — пропс, переписанный в useState. Выглядит безобидно: родитель передал оттенок, компонент его «сохранил». Родитель ниже меняет пропс по кнопке; два дочерних компонента отличаются одной строкой. Предскажите, что покажет каждый.
Родитель перерисовался с новым пропсом, Copied тоже был вызван заново — но useState(tone) смотрит на аргумент только при первой отрисовке; вы видели это в статье про useState, когда начальное значение считалось зря. Дальше хук возвращает свою память, а в памяти — старая копия. Компонент замер на значении, с которым его создали, и никакая перерисовка родителя его не разбудит.
Копировать пропс в состояние осмысленно ровно в одном случае: компоненту нужно только начальное значение, а дальше он живёт сам — поле получило текст черновика, и пользователь его правит. Тогда пропс так и называют: initialText, defaultTone. Имя предупреждает вызывающего: последующие изменения ты не увидишь, и это не ошибка.
Если пропс нужен в разметке — берите его из аргументов. Состояние заводят для того, что меняет сам компонент.
Список записей и выбранная запись — пара из каждого второго интерфейса. Первое решение напрашивается само: положить выбранный объект в отдельное состояние. Пример выбирает «кофе», затем переименовывает его в списке. Предскажите строку «выбрано».
Список обновился, а строка «выбрано» — нет. Обработчик переименования сделал всё как положено по статье об объектах в состоянии: map вернул новый массив с новым объектом для кофе. Но в selected лежит не ссылка на «кофе из списка», а отдельный объект — тот, что был в списке в момент выбора. Два состояния описывают одну запись, и держать их в согласии придётся руками в каждом обработчике, который редактирует список.
Дублируется здесь не объект, а знание: «какая запись выбрана». Его хватает выразить одним числом — идентификатором. Саму запись находят в теле компонента, как результат поиска из первого раздела.
Тип у selected вывелся сам: Item | undefined — find честно предупреждает, что записи с таким id может не оказаться. Проверка на undefined в разметке не перестраховка: без неё компилятор не даст взять title, это сужение из части III. Заодно исчезла целая семья ошибок: удалили запись — выбор сам стал пустым, переименовали — переименовалось везде.
Id вместо объекта — та же мысль, что data-id в делегировании из части II: обработчик хранил не узел и не объект, а ключ, по которому всё находится.
Отправка формы проходит стадии: идёт запрос, пришёл ответ, случилась ошибка. Три булевых состояния напрашиваются сами, и каждое по отдельности выглядит невинно. Пример изображает отправку, которая с первого раза падает, а со второго проходит. Предскажите текст абзаца после второй попытки.
Замена противоречащим флагам — одно состояние с литеральным юнионом, как в статье про useState. Стадии одного процесса по определению исключают друг друга, и одна ячейка выражает это сама: переход в новую стадию стирает предыдущую.
Подписи собраны в Record<Status, string> из части III не ради красоты: добавите стадию "retrying" — компилятор потребует подпись и для неё. Флаги при этом не запрещены: isOpen у выпадающего списка — честное булево, оба его значения осмысленны. Подозрение должно вызывать несколько булевых про один процесс. Если ловите себя на мысли «когда это true, то вон то обязано быть false» — перед вами стадии одного статуса.
Вместе с фактом ошибки обычно нужен её текст. Хранить его отдельным состоянием errorText — снова дубликат: пустая строка при статусе «error» и текст при статусе «sent» одинаково бессмысленны. Размеченное объединение из части III кладёт текст туда, где он существует, — и компилятор не даст прочитать message у других стадий. Такие объединения пригодятся и дальше: в статье «useReducer и размеченные объединения» ими описывают действия, а все переходы между состояниями собирают в одну функцию.
Обратный вопрос: сколько заводить состояний? Форма с тремя полями — три useState или один объект? React не настаивает ни на чём, но у объекта есть цена, и компилятор её показывает.
Компилятор требует email и agreed: частичного обновления у объекта в состоянии нет, только замена целиком, — поэтому в статье об объектах в состоянии каждое изменение начиналось с { ...form, ... }. Для трёх полей, которые меняются каждое от своей кнопки, это налог без выгоды. Разделите их — и копировать станет нечего.
Обратная ситуация — значения, которые всегда меняются одним движением: координаты указателя, начало и конец выделения, ответ сервера, который приходит целиком. Их разделять вредно: два set в одном обработчике объединятся в одну отрисовку, но второй вызов легко забыть, и точка окажется с новым x и старым y. Правило симметричное: вместе — то, что меняется вместе; порознь — то, что меняется порознь.
| Значения | Как меняются | Структура |
|---|---|---|
x и y указателя | всегда одним движением | один объект { x, y } |
name, email, agreed формы | каждое от своего поля | три useState |
from и to выделения | вместе | один объект |
items и query | независимо | два useState |
ответ сервера user | приходит целиком | один объект, как пришёл |
Третья крайность — объект в объекте в объекте: категория содержит подкатегории, те — товары. Чтобы поменять название одного товара, придётся скопировать весь путь от корня до него — по правилам неизменяемости из статьи об объектах в состоянии. Выход тот же, что с выбранной записью: плоский словарь по id, где связи хранятся ключами. Изменение одного узла трогает одну запись.
- В состоянии лежит только то, что нельзя вычислить из остального состояния и пропсов; всё производное — константы в теле компонента, они пересчитываются на каждой отрисовке сами.
- Пропс попадает в
useStateодин раз: если значение нужно в разметке — читайте пропс, а «только начальное» называйтеinitialX. - Одна запись в двух состояниях расходится при первом же редактировании: храните id, а запись находите в списке через
find. - Несколько булевых про один процесс допускают бессмысленные сочетания; стадии описывают одним состоянием с литеральным юнионом.
- Вместе — то, что меняется вместе; порознь — то, что меняется независимо. Один объект заставляет копировать все поля при каждом изменении, и компилятор напомнит об этом.
1/4В компоненте есть состояние tasks и состояние doneCount, которое обновляется в обработчике «отметить готовой». Добавили обработчик удаления, который меняет только tasks. Что покажет doneCount после удаления готовой задачи?
2/4Компонент получает пропс title и делает const [text] = React.useState(title). Родитель перерисовался и передал новый title. Что покажет компонент?
3/4В состоянии лежат items: Item[] и selected: Item | null — копия выбранной записи. Запись переименовали через setItems(items.map(...)). Что произойдёт с selected?
4/4Для загрузки данных заведены isLoading, isError и data. Что здесь признак, что структуру стоит пересмотреть?
Счётчик готовых
Доска показывает «готово: N из M». После того как задачу отметили готовой и удалили, счётчик врёт: готовых ноль, а показывает единицу. Почините так, чтобы врать было нечему — уберите лишнее состояние.
Число готовых целиком вычисляется из массива задач, поэтому в состоянии ему делать нечего. Убираем doneCount из useState и считаем его в теле компонента; обработчик finish при этом перестаёт помнить про счётчик, а remove ничего и не знал.
После правки вывод — «готово: 0 из 1». Любой будущий обработчик — «отметить все», «очистить готовые» — тоже не сможет сломать счётчик: ему просто нечего забыть.
Плеер с двумя флагами
Плеер хранит isPlaying и isPaused. Нажали «играть», потом «пауза» — и абзац сообщает «играет на паузе». Замените два флага одним состоянием так, чтобы такое сообщение стало невозможным, а не просто маловероятным.
Три режима плеера исключают друг друга — это стадии, а не независимые признаки. Одно состояние с литеральным юнионом: переход в новый режим сам стирает предыдущий.
Если позже понадобится режим «загрузка», его добавляют в Mode — и компилятор потребует подпись в label, а заодно найдёт все места, где режимы перебираются без учёта нового.
Приветствие, которое не обновляется
Родитель после «входа» передаёт новое имя, а приветствие продолжает обращаться к гостю. Найдите лишнюю строку и почините, не трогая родителя.
Строка React.useState(name) скопировала пропс один раз и больше к нему не возвращалась. Имя компонент не меняет сам — значит, состояние ему не нужно, пропс читается напрямую.
Копия имела бы смысл, если бы компонент давал имя редактировать и хотел начать с переданного; тогда пропс назвали бы initialName, чтобы вызывающий не ждал от него обновлений.