Управляемые поля: состояние вместо DOM

события≈ 13 минут

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

В части II текст поля жил в самом узле: render() собирал разметку из состояния, набранное читалось из DOM, и любая перерисовка его стирала. React переворачивает схему: текст живёт в состоянии, поле лишь показывает его, а каждое нажатие клавиши идёт по кругу «событие — сеттер — отрисовка — value». Такое поле называют управляемым. Разберём, сколько отрисовок стоит один символ, что лежит в обработчике в момент ввода и почему поле без onChange перестаёт принимать буквы.

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

У поля ввода в доске задач из части II было две правды. Одна — в объекте состояния, из которого render() собирал разметку. Другая — в свойстве value самого узла, куда браузер складывал нажатые клавиши. Пока форма читалась один раз при отправке, две правды не мешали друг другу. Но стоило показать набранное где-то ещё — в счётчике символов, в подсказке под полем, — приходилось выбирать: либо перечитывать input.value в каждом месте, либо звать render(), который стирал само поле (статья «Зачем React, если вы уже писали рендер из состояния»).

Управляемое поле оставляет одну правду — состояние. Пропс value говорит: «поле показывает эту переменную». Пропс onChange — «когда человек что-то набрал, положи набранное в переменную». Предскажите, что напечатает пример, когда в поле появится слово «кофе»: сколько будет строк «отрисовка» и что окажется в абзаце под полем.

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

Две отрисовки: при монтировании и после ввода. Круг замкнулся за один проход: событие input из части II дошло до React, тот вызвал onChange, сеттер положил в состояние новый текст, компонент отрисовался заново — и абзац, и поле взяли значение из одной переменной. Ни поиска по DOM, ни второго источника: query — единственное место, где этот текст существует.

В кадре можно печатать самому, но выводу нужен воспроизводимый сценарий, поэтому пример набирает текст сам. Просто присвоить el.value = "кофе" нельзя: React подменяет свойство value у своих полей, чтобы отличать ввод человека от присваивания из кода, и такое присваивание вводом не считает — событие input после него до onChange не дойдёт. Поэтому помощник зовёт исходный сеттер из HTMLInputElement.prototype и рассылает input сам — ровно то, что делает браузер при нажатии клавиши. Правило из статьи «input, change и живой ввод» — «присваивание из кода событий не порождает» — здесь никуда не делось, React лишь добавил к нему свою проверку.

value и onChange ходят парой. value без onChange даёт поле, которое нельзя редактировать (раздел ниже); onChange без value — поле, о содержимом которого React не знает. О вторых — следующая статья.

Один символ — одно событие input — один вызов сеттера — одна отрисовка. Значит, слово из четырёх букв, набранное по одной, вызовет компонент четыре раза. Проверим — и заодно посмотрим, что будет, если текст вставить из буфера обмена целиком.

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

Четыре строки на четыре нажатия — и одна на вставку. Считаются не символы, а события: браузер шлёт input на каждое изменение значения, а вставка — одно изменение, сколько бы букв она ни принесла. Обратите внимание и на имя пропса. onChange в React срабатывает на каждое изменение, как DOM-событие input из части II, а не как change, которое ждёт потери фокуса. Имя историческое, и оно сбивает с толку многих; таблица расставляет соответствия.

Событие DOM из части IIКогда приходитВ React
inputпосле каждого изменения значенияonChange
changeкогда правка закончена: поле потеряло фокус или нажат Enterсвоего пропса нет; ближе всего onBlur
переключение чекбокса или радиокнопкисразутот же onChange, читать checked

Отрисовка на каждую букву звучит расточительно, но вспомните, что это такое: вызов функции и сравнение описаний, а в DOM на коммите меняется только то, что отличается (статья «Триггер, рендер, коммит»). Значение поля уже совпадает с состоянием — его React даже не трогает. Дорогим бывает не сам вызов, а то, что вы делаете внутри него на каждую букву, — например, фильтруете тысячу строк. Как не дать такому списку тормозить набор, разбирает статья «Срочное и несрочное: useTransition и useDeferredValue» в главе про скорость.

console.log первой строкой в теле компонента — тот же счётчик отрисовок, что и в главе про состояние: он считает вызовы функции, а не изменения экрана.

В обработчике клика count был снимком отрисовки, а не живой переменной (статья «Состояние — снимок, а не переменная»). Для поля это правило даёт конкретное следствие: в момент, когда вызывается onChange, буква уже в DOM, а в состоянии её ещё нет. Предскажите четыре строки вывода.

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

e.target.value — текст, который только что появился в узле; query — то, что было в состоянии на момент этой отрисовки. Ни во второй, ни в четвёртой строке query не догнал поле: сеттер планирует следующую отрисовку, а не правит текущую. Отсюда правило: новое значение берётся из события, а не из состояния. Понадобилось тут же проверить длину или подобрать подсказку — проверяйте e.target.value или локальную переменную, в которую вы его положили.

Заметьте, что e.target.value скомпилировался без единого приведения. В статье про обработчики event.target у клика приходилось сужать вручную: там он EventTarget, потому что кликнуть могли по чему угодно внутри кнопки. onChange у <input> получает React.ChangeEvent<HTMLInputElement>, и в этом типе target объявлен как EventTarget & HTMLInputElement: значение меняется у самого поля, другого target у события быть не может. Вынесенной функции, как и в случае с кликом, аннотация нужна явно. Предскажите, что окажется в поле, если набрать «кофе» с тремя пробелами впереди.

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

Пробелы пропали из самого поля, хотя в узел они попали: в состояние легла обрезанная строка, отрисовка отдала её в value, и React привёл узел к состоянию. Поле показывает не то, что набрали, а то, что лежит в переменной, — и следующий раздел доводит это наблюдение до конца.

Аннотация строится по той же схеме, что React.MouseEvent<HTMLButtonElement>: React.ChangeEvent<HTMLInputElement> для input, <HTMLTextAreaElement> для textarea, <HTMLSelectElement> для select.

В части II вы выучили, что value у поля — всегда строка, даже у type="number". Управляемое поле сталкивает это правило с типом состояния. Ниже возраст хранится числом, а в сеттер уезжает e.target.value. Перед запуском ответьте на два вопроса: что скажет компилятор и что окажется в подписи «через год», когда в поле наберут 20.

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

Компилятор отказывается: аргумент типа string не годится для SetStateAction<number> — того самого типа сеттера из статьи про useState. А в кадре под примером — «201»: типы стёрты, строка «20» легла в состояние, и age + 1 сработал как склейка строк из части I. В подписи это безобидно, но та же строка вместо числа уедет в расчёт цены или в запрос — и молча.

Чинить можно двумя способами. Первый напрашивается сам: превращать строку в число в обработчике — setAge(Number(e.target.value)). Второй — честно хранить в состоянии то, что лежит в поле, то есть строку, и получать число там, где оно нужно.

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

Второй способ скромнее на вид, но переживает всё, что человек может набрать: пустое поле, минус без цифр, недописанное 1e. У числового состояния для таких промежуточных положений нет представления: пустое поле — это не ноль и не NaN, а «ещё не ввели». Отсюда правило: в состоянии управляемого поля лежит то, что лежит в поле, а производные значения считаются при отрисовке — без отдельного состояния и без эффекта (статья «Когда эффект не нужен»).

У числового поля есть valueAsNumber из части II, и с ним компилятор доволен: setAge(e.target.valueAsNumber). Но у пустого или недописанного поля это NaN. Он ляжет в состояние, а оттуда отрисовка отдаст его обратно в value — и в узел уедет строка «NaN», которую числовое поле принять не может: браузер очищает его, и только что набранный минус исчезает у человека из-под пальцев. Строка в состоянии таких сюрпризов не устраивает.

Теперь можно описать, что React делает после каждого события ввода: вызывает onChange, дожидается отрисовки и сверяет узел с value. Разошлись — записывает в узел то, что в value. Отсюда самый частый вопрос первой недели с React: «почему в моё поле нельзя печатать». Ниже три поля, и в каждое пример набирает текст. Предскажите, что останется в каждом.

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

Первое поле не приняло ни буквы: value есть, а сеттера, который изменил бы состояние, нет — и после каждого события React возвращает «Рико». Второе поле показывает, что дело не в наличии обработчика. onChange вызвался и увидел «Гость», но состояние не поменял, и результат тот же. React не проверяет, написали ли вы обработчик; он проверяет, совпадает ли узел с value. Первой строкой вывода первое поле получило ещё и предупреждение: React советует взять defaultValue или readOnly. Это отладочная сборка — в песочнице стоит именно она; в боевой предупреждения нет, и поле молча не принимает буквы.

Третье поле — та же «заморозка», поставленная на службу. В части II фильтр на лету перезаписывал value прямо в обработчике. Здесь достаточно не звать сеттер, когда ввод не подходит: буква x дошла до узла, состояние осталось «12», и React вернул узел к состоянию. Обработчик решает, что попадёт в правду; поле следует за правдой.

Нужно поле только для чтения — пишите readOnly. Браузер покажет человеку, что править нельзя, а React перестанет ждать onChange и не будет предупреждать.

Если состояние вам не нужно вовсе, а нужно лишь начальное значение — как у атрибута value из части II, — для этого есть defaultValue. О нём, о ref и о том, когда поле лучше оставить браузеру, — следующая статья.

Форма из восьми полей не означает восемь useState. В части II один слушатель на форме различал поля по event.target.name (статья про input и change); в React тот же приём — объект в состоянии и один обработчик, который кладёт значение по ключу name. Ключ вычисляемый — синтаксис из части I; копия вместо правки на месте — правило из статьи про объекты в состоянии. Чекбокс выбивается из ряда: у него правда лежит не в value, а в checked, и управляют им пропсом с тем же именем. Сколько строк «отрисовка» даст этот сценарий?

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

Четыре отрисовки на три поля: первая при монтировании, дальше по одной на событие. У чекбокса значение читается из checked, а событие для него React поднимает по клику — галочка переключается мгновенно, ждать нечего. e.target.name компилятор знает как string благодаря тому же EventTarget & HTMLInputElement. А вот совпадает ли этот name с ключом в Profile, он проверить не может: строка есть строка. Опечатка в атрибуте name заведёт в состояние лишнее поле, и ни одна диагностика об этом не скажет.

Если поля известны заранее, обработчик можно не выбирать по строке из DOM, а создавать по ключу: фабрика принимает keyof Profile, и опечатка в аргументе становится ошибкой компиляции. Цена — по стрелке на каждое поле, что для формы обычного размера ничего не стоит. Ошибка ниже нарочная — прочитайте, что скажет компилятор.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
ПолеКакой проп управляетЧто читать в обработчике
текстовый input, textareavaluee.target.value
input type="number"valuee.target.value — строка
чекбоксcheckede.target.checked
группа радиокнопокchecked={choice === "…"} у каждойe.target.value
selectvalue на самом select, не selected у optione.target.value

textarea в React — тоже value, а не текст между тегами; select — value на самом select. Оба отступления от HTML сделаны ради одного: у каждого поля ровно один проп-правда.

  • Управляемое поле — то, у которого value привязан к состоянию, а onChange кладёт в состояние новый текст: правда одна, и это переменная, а не узел DOM.
  • Один символ — одно событие input — одна отрисовка; вставка из буфера — одно событие. onChange в React срабатывает как DOM-событие input, а не как change.
  • В момент onChange e.target.value уже новое, а переменная состояния — ещё снимок прошлой отрисовки; новое значение берут из события.
  • value у поля — всегда строка, и компилятор не даст положить её в состояние number; в состоянии держат то, что лежит в поле, а число считают при отрисовке.
  • После каждого события React сверяет узел с value и возвращает узел к состоянию: поле без сеттера замерзает, а обработчик, не вызвавший сеттер, работает как фильтр.
  • Форма из многих полей — объект в состоянии и один обработчик по e.target.name; чекбоксом управляет checked, textarea и select — value.

1/4В управляемое поле по одной букве набрали слово «чай». Сколько раз после этого вызовется функция компонента?

2/4У поля написано value={text} и onChange={(e) => console.log(e.target.value)}. Что случится, когда человек наберёт букву?

3/4Внутри onChange написано setText(e.target.value), а следующей строкой — console.log(text). В состоянии было «ко», человек набрал третью букву, и в поле стало «кот». Что напечатается?

4/4Когда в React срабатывает onChange у текстового поля?

Счётчик оставшихся символов

Та же задача, что в части II, — теперь на React. Под полем должно стоять «Осталось: N», где N убывает с каждым символом, а кнопка «Отправить» выключается, когда лимит превышен. Сейчас счётчик стоит на месте: поле неуправляемое, и о наборе никто не узнаёт. Переведите поле в управляемое. Отдельное состояние для remaining не заводите — это производное значение.

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

Текст переезжает в состояние, textarea получает value и onChange, а remaining считается прямо в теле компонента из text.length: он всегда согласован с текстом и не нуждается ни во втором состоянии, ни в эффекте.

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

Тип события у стрелки выводится сам — React.ChangeEvent<HTMLTextAreaElement>, поэтому e.target.value доступен без приведения. Вторая проверка даёт true: длинный текст ушёл в минус, и disabled пересчитался в той же отрисовке.

Только цифры, и не больше десяти

Поле для номера телефона должно принимать только цифры и не длиннее десяти. В части II это делал фильтр, перезаписывающий value в обработчике. Сделайте то же управляемым полем, не трогая DOM руками: обработчик решает, попадёт ли ввод в состояние.

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

Достаточно одного условия перед сеттером: регулярное выражение из части I проверяет, что весь новый текст — от нуля до десяти цифр. Не прошёл — сеттер не вызывается, состояние не меняется, и React возвращает узел к прежнему значению.

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

Заметьте, что вставка одиннадцати цифр отвергается целиком, а не обрезается до десяти. Если нужна обрезка — положите в состояние e.target.value.replace(/\D/g, "").slice(0, 10): обработчик вправе класть в правду не то, что набрали, а то, что из этого получилось, — как trimStart в статье.

Что напечатает пример

Не запуская, выпишите вывод целиком: сколько будет строк «отрисовка», в каком порядке они перемешаются со строками из обработчика и что окажется в поле в конце. Человек трижды набирает строчные буквы, а обработчик кладёт в состояние прописные.

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

Восемь строк, и строчных букв в них нет нигде, кроме второй половины строк обработчика:

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

Строка из обработчика каждый раз идёт раньше отрисовки: сеттер только оставляет заявку, а компонент вызывается после того, как обработчик доработал. В code при этом лежит снимок — значение прошлой отрисовки, поэтому слева от стрелки всегда «вчерашнее» состояние. А строчные буквы, которые человек набирал, в поле не задержались ни на одном шаге: в состояние легла прописная версия, отрисовка отдала её в value, и React привёл узел к состоянию — тот же механизм, что и у trimStart в статье.