Управляемые поля: состояние вместо DOM
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В части II текст поля жил в самом узле: render() собирал разметку из состояния, набранное читалось из DOM, и любая перерисовка его стирала. React переворачивает схему: текст живёт в состоянии, поле лишь показывает его, а каждое нажатие клавиши идёт по кругу «событие — сеттер — отрисовка — value». Такое поле называют управляемым. Разберём, сколько отрисовок стоит один символ, что лежит в обработчике в момент ввода и почему поле без onChange перестаёт принимать буквы.
У поля ввода в доске задач из части II было две правды. Одна — в объекте состояния, из которого render() собирал разметку. Другая — в свойстве value самого узла, куда браузер складывал нажатые клавиши. Пока форма читалась один раз при отправке, две правды не мешали друг другу. Но стоило показать набранное где-то ещё — в счётчике символов, в подсказке под полем, — приходилось выбирать: либо перечитывать input.value в каждом месте, либо звать render(), который стирал само поле (статья «Зачем React, если вы уже писали рендер из состояния»).
Управляемое поле оставляет одну правду — состояние. Пропс value говорит: «поле показывает эту переменную». Пропс onChange — «когда человек что-то набрал, положи набранное в переменную». Предскажите, что напечатает пример, когда в поле появится слово «кофе»: сколько будет строк «отрисовка» и что окажется в абзаце под полем.
Две отрисовки: при монтировании и после ввода. Круг замкнулся за один проход: событие 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 — один вызов сеттера — одна отрисовка. Значит, слово из четырёх букв, набранное по одной, вызовет компонент четыре раза. Проверим — и заодно посмотрим, что будет, если текст вставить из буфера обмена целиком.
Четыре строки на четыре нажатия — и одна на вставку. Считаются не символы, а события: браузер шлёт 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, а в состоянии её ещё нет. Предскажите четыре строки вывода.
e.target.value — текст, который только что появился в узле; query — то, что было в состоянии на момент этой отрисовки. Ни во второй, ни в четвёртой строке query не догнал поле: сеттер планирует следующую отрисовку, а не правит текущую. Отсюда правило: новое значение берётся из события, а не из состояния. Понадобилось тут же проверить длину или подобрать подсказку — проверяйте e.target.value или локальную переменную, в которую вы его положили.
Заметьте, что e.target.value скомпилировался без единого приведения. В статье про обработчики event.target у клика приходилось сужать вручную: там он EventTarget, потому что кликнуть могли по чему угодно внутри кнопки. onChange у <input> получает React.ChangeEvent<HTMLInputElement>, и в этом типе target объявлен как EventTarget & HTMLInputElement: значение меняется у самого поля, другого target у события быть не может. Вынесенной функции, как и в случае с кликом, аннотация нужна явно. Предскажите, что окажется в поле, если набрать «кофе» с тремя пробелами впереди.
Пробелы пропали из самого поля, хотя в узел они попали: в состояние легла обрезанная строка, отрисовка отдала её в value, и React привёл узел к состоянию. Поле показывает не то, что набрали, а то, что лежит в переменной, — и следующий раздел доводит это наблюдение до конца.
Аннотация строится по той же схеме, что React.MouseEvent<HTMLButtonElement>: React.ChangeEvent<HTMLInputElement> для input, <HTMLTextAreaElement> для textarea, <HTMLSelectElement> для select.
В части II вы выучили, что value у поля — всегда строка, даже у type="number". Управляемое поле сталкивает это правило с типом состояния. Ниже возраст хранится числом, а в сеттер уезжает e.target.value. Перед запуском ответьте на два вопроса: что скажет компилятор и что окажется в подписи «через год», когда в поле наберут 20.
Компилятор отказывается: аргумент типа string не годится для SetStateAction<number> — того самого типа сеттера из статьи про useState. А в кадре под примером — «201»: типы стёрты, строка «20» легла в состояние, и age + 1 сработал как склейка строк из части I. В подписи это безобидно, но та же строка вместо числа уедет в расчёт цены или в запрос — и молча.
Чинить можно двумя способами. Первый напрашивается сам: превращать строку в число в обработчике — setAge(Number(e.target.value)). Второй — честно хранить в состоянии то, что лежит в поле, то есть строку, и получать число там, где оно нужно.
Второй способ скромнее на вид, но переживает всё, что человек может набрать: пустое поле, минус без цифр, недописанное 1e. У числового состояния для таких промежуточных положений нет представления: пустое поле — это не ноль и не NaN, а «ещё не ввели». Отсюда правило: в состоянии управляемого поля лежит то, что лежит в поле, а производные значения считаются при отрисовке — без отдельного состояния и без эффекта (статья «Когда эффект не нужен»).
У числового поля есть valueAsNumber из части II, и с ним компилятор доволен: setAge(e.target.valueAsNumber). Но у пустого или недописанного поля это NaN. Он ляжет в состояние, а оттуда отрисовка отдаст его обратно в value — и в узел уедет строка «NaN», которую числовое поле принять не может: браузер очищает его, и только что набранный минус исчезает у человека из-под пальцев. Строка в состоянии таких сюрпризов не устраивает.
Теперь можно описать, что React делает после каждого события ввода: вызывает onChange, дожидается отрисовки и сверяет узел с value. Разошлись — записывает в узел то, что в value. Отсюда самый частый вопрос первой недели с React: «почему в моё поле нельзя печатать». Ниже три поля, и в каждое пример набирает текст. Предскажите, что останется в каждом.
Первое поле не приняло ни буквы: 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, и управляют им пропсом с тем же именем. Сколько строк «отрисовка» даст этот сценарий?
Четыре отрисовки на три поля: первая при монтировании, дальше по одной на событие. У чекбокса значение читается из checked, а событие для него React поднимает по клику — галочка переключается мгновенно, ждать нечего. e.target.name компилятор знает как string благодаря тому же EventTarget & HTMLInputElement. А вот совпадает ли этот name с ключом в Profile, он проверить не может: строка есть строка. Опечатка в атрибуте name заведёт в состояние лишнее поле, и ни одна диагностика об этом не скажет.
Если поля известны заранее, обработчик можно не выбирать по строке из DOM, а создавать по ключу: фабрика принимает keyof Profile, и опечатка в аргументе становится ошибкой компиляции. Цена — по стрелке на каждое поле, что для формы обычного размера ничего не стоит. Ошибка ниже нарочная — прочитайте, что скажет компилятор.
| Поле | Какой проп управляет | Что читать в обработчике |
|---|---|---|
текстовый input, textarea | value | e.target.value |
input type="number" | value | e.target.value — строка |
| чекбокс | checked | e.target.checked |
| группа радиокнопок | checked={choice === "…"} у каждой | e.target.value |
select | value на самом select, не selected у option | e.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 не заводите — это производное значение.
Текст переезжает в состояние, textarea получает value и onChange, а remaining считается прямо в теле компонента из text.length: он всегда согласован с текстом и не нуждается ни во втором состоянии, ни в эффекте.
Тип события у стрелки выводится сам — React.ChangeEvent<HTMLTextAreaElement>, поэтому e.target.value доступен без приведения. Вторая проверка даёт true: длинный текст ушёл в минус, и disabled пересчитался в той же отрисовке.
Только цифры, и не больше десяти
Поле для номера телефона должно принимать только цифры и не длиннее десяти. В части II это делал фильтр, перезаписывающий value в обработчике. Сделайте то же управляемым полем, не трогая DOM руками: обработчик решает, попадёт ли ввод в состояние.
Достаточно одного условия перед сеттером: регулярное выражение из части I проверяет, что весь новый текст — от нуля до десяти цифр. Не прошёл — сеттер не вызывается, состояние не меняется, и React возвращает узел к прежнему значению.
Заметьте, что вставка одиннадцати цифр отвергается целиком, а не обрезается до десяти. Если нужна обрезка — положите в состояние e.target.value.replace(/\D/g, "").slice(0, 10): обработчик вправе класть в правду не то, что набрали, а то, что из этого получилось, — как trimStart в статье.
Что напечатает пример
Не запуская, выпишите вывод целиком: сколько будет строк «отрисовка», в каком порядке они перемешаются со строками из обработчика и что окажется в поле в конце. Человек трижды набирает строчные буквы, а обработчик кладёт в состояние прописные.
Восемь строк, и строчных букв в них нет нигде, кроме второй половины строк обработчика:
Строка из обработчика каждый раз идёт раньше отрисовки: сеттер только оставляет заявку, а компонент вызывается после того, как обработчик доработал. В code при этом лежит снимок — значение прошлой отрисовки, поэтому слева от стрелки всегда «вчерашнее» состояние. А строчные буквы, которые человек набирал, в поле не задержались ни на одном шаге: в состояние легла прописная версия, отрисовка отдала её в value, и React привёл узел к состоянию — тот же механизм, что и у trimStart в статье.