Как выбирать структуру состояния

состояние≈ 11 минут

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

В проекте части II состояние доски помещалось в один массив задач, а число готовых и отфильтрованный список render() считал заново при каждом вызове. Стоило завести для них отдельные переменные — и они начинали расходиться с массивом. В React соблазн тот же, только вместо переменных — лишние useState, и расходятся они точно так же. Правило одно: в состоянии лежит только то, что нельзя вычислить из остального.

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

Список товаров и поиск по нему. Результат поиска кажется полноправным состоянием: он меняется, он показывается на экране, у него есть сеттер. Пример добавляет в список «каркаде» — товар, который подходит под текущий запрос «ка». Предскажите, появится ли он в абзаце.

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

Каркаде попал в items, а абзац не изменился: found никто не пересчитал. Это не оплошность в одном обработчике, а свойство конструкции. У результата поиска два источника — список и запрос, — и каждый обработчик, который трогает любой из них, обязан помнить про третье состояние. В части II так же расходились массив задач и счётчик готовых, пока подсчёт не переехал внутрь render().

Лечится точно так же: результат считается в теле компонента. Тело выполняется на каждой отрисовке, а отрисовку вызывает любое изменение состояния, — значит, пересчёт нельзя забыть, он происходит сам.

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

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

Вопрос «не дорого ли фильтровать на каждой отрисовке» законный, но преждевременный: для сотни строк это незаметно. Когда станет заметно, есть useMemo — глава про скорость. Сперва правильная структура, потом ускорение.

Список кандидатов на выселение из состояния у всех проектов примерно один.

Хочется положить в состояниеИз чего считаетсяЧто писать в теле компонента
fullNamefirstName и lastNamefirstName + " " + lastName
doneCountмассив задачtasks.filter((t) => t.done).length
isEmptyмассивitems.length === 0
total корзинысписок позицийreduce по позициям
canSubmitполя формыname !== "" && agreed

Особый случай лишнего состояния — пропс, переписанный в useState. Выглядит безобидно: родитель передал оттенок, компонент его «сохранил». Родитель ниже меняет пропс по кнопке; два дочерних компонента отличаются одной строкой. Предскажите, что покажет каждый.

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

Родитель перерисовался с новым пропсом, Copied тоже был вызван заново — но useState(tone) смотрит на аргумент только при первой отрисовке; вы видели это в статье про useState, когда начальное значение считалось зря. Дальше хук возвращает свою память, а в памяти — старая копия. Компонент замер на значении, с которым его создали, и никакая перерисовка родителя его не разбудит.

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

Если пропс нужен в разметке — берите его из аргументов. Состояние заводят для того, что меняет сам компонент.

Список записей и выбранная запись — пара из каждого второго интерфейса. Первое решение напрашивается само: положить выбранный объект в отдельное состояние. Пример выбирает «кофе», затем переименовывает его в списке. Предскажите строку «выбрано».

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

Список обновился, а строка «выбрано» — нет. Обработчик переименования сделал всё как положено по статье об объектах в состоянии: map вернул новый массив с новым объектом для кофе. Но в selected лежит не ссылка на «кофе из списка», а отдельный объект — тот, что был в списке в момент выбора. Два состояния описывают одну запись, и держать их в согласии придётся руками в каждом обработчике, который редактирует список.

Дублируется здесь не объект, а знание: «какая запись выбрана». Его хватает выразить одним числом — идентификатором. Саму запись находят в теле компонента, как результат поиска из первого раздела.

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

Тип у selected вывелся сам: Item | undefined — find честно предупреждает, что записи с таким id может не оказаться. Проверка на undefined в разметке не перестраховка: без неё компилятор не даст взять title, это сужение из части III. Заодно исчезла целая семья ошибок: удалили запись — выбор сам стал пустым, переименовали — переименовалось везде.

Id вместо объекта — та же мысль, что data-id в делегировании из части II: обработчик хранил не узел и не объект, а ключ, по которому всё находится.

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

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
«ошибка! отправлено» — сообщение, которого не бывает в природе, и пользователь его увидел. Ни один обработчик не сделал ничего неправильного. Просто три независимых флага допускают восемь сочетаний, а смысл есть у трёх: «ждём», «получилось», «не получилось». Остальные пять рано или поздно случатся, потому что согласовывать флаги приходится руками — как и всё в этой статье.

Замена противоречащим флагам — одно состояние с литеральным юнионом, как в статье про useState. Стадии одного процесса по определению исключают друг друга, и одна ячейка выражает это сама: переход в новую стадию стирает предыдущую.

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

Подписи собраны в Record<Status, string> из части III не ради красоты: добавите стадию "retrying" — компилятор потребует подпись и для неё. Флаги при этом не запрещены: isOpen у выпадающего списка — честное булево, оба его значения осмысленны. Подозрение должно вызывать несколько булевых про один процесс. Если ловите себя на мысли «когда это true, то вон то обязано быть false» — перед вами стадии одного статуса.

Вместе с фактом ошибки обычно нужен её текст. Хранить его отдельным состоянием errorText — снова дубликат: пустая строка при статусе «error» и текст при статусе «sent» одинаково бессмысленны. Размеченное объединение из части III кладёт текст туда, где он существует, — и компилятор не даст прочитать message у других стадий. Такие объединения пригодятся и дальше: в статье «useReducer и размеченные объединения» ими описывают действия, а все переходы между состояниями собирают в одну функцию.

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

Обратный вопрос: сколько заводить состояний? Форма с тремя полями — три useState или один объект? React не настаивает ни на чём, но у объекта есть цена, и компилятор её показывает.

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

Компилятор требует email и agreed: частичного обновления у объекта в состоянии нет, только замена целиком, — поэтому в статье об объектах в состоянии каждое изменение начиналось с { ...form, ... }. Для трёх полей, которые меняются каждое от своей кнопки, это налог без выгоды. Разделите их — и копировать станет нечего.

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

Обратная ситуация — значения, которые всегда меняются одним движением: координаты указателя, начало и конец выделения, ответ сервера, который приходит целиком. Их разделять вредно: два set в одном обработчике объединятся в одну отрисовку, но второй вызов легко забыть, и точка окажется с новым x и старым y. Правило симметричное: вместе — то, что меняется вместе; порознь — то, что меняется порознь.

ЗначенияКак меняютсяСтруктура
x и y указателявсегда одним движениемодин объект { x, y }
name, email, agreed формыкаждое от своего полятри useState
from и to выделениявместеодин объект
items и queryнезависимодва useState
ответ сервера userприходит целикомодин объект, как пришёл

Третья крайность — объект в объекте в объекте: категория содержит подкатегории, те — товары. Чтобы поменять название одного товара, придётся скопировать весь путь от корня до него — по правилам неизменяемости из статьи об объектах в состоянии. Выход тот же, что с выбранной записью: плоский словарь по id, где связи хранятся ключами. Изменение одного узла трогает одну запись.

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

  • В состоянии лежит только то, что нельзя вычислить из остального состояния и пропсов; всё производное — константы в теле компонента, они пересчитываются на каждой отрисовке сами.
  • Пропс попадает в 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». После того как задачу отметили готовой и удалили, счётчик врёт: готовых ноль, а показывает единицу. Почините так, чтобы врать было нечему — уберите лишнее состояние.

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

Число готовых целиком вычисляется из массива задач, поэтому в состоянии ему делать нечего. Убираем doneCount из useState и считаем его в теле компонента; обработчик finish при этом перестаёт помнить про счётчик, а remove ничего и не знал.

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

После правки вывод — «готово: 0 из 1». Любой будущий обработчик — «отметить все», «очистить готовые» — тоже не сможет сломать счётчик: ему просто нечего забыть.

Плеер с двумя флагами

Плеер хранит isPlaying и isPaused. Нажали «играть», потом «пауза» — и абзац сообщает «играет на паузе». Замените два флага одним состоянием так, чтобы такое сообщение стало невозможным, а не просто маловероятным.

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

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

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

Если позже понадобится режим «загрузка», его добавляют в Mode — и компилятор потребует подпись в label, а заодно найдёт все места, где режимы перебираются без учёта нового.

Приветствие, которое не обновляется

Родитель после «входа» передаёт новое имя, а приветствие продолжает обращаться к гостю. Найдите лишнюю строку и почините, не трогая родителя.

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

Строка React.useState(name) скопировала пропс один раз и больше к нему не возвращалась. Имя компонент не меняет сам — значит, состояние ему не нужно, пропс читается напрямую.

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

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