Неуправляемые поля, ref и FormData
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В предыдущей статье каждое нажатие клавиши шло по кругу: символ — сеттер — перерисовка — value. Для поля поиска с подсказками это оправдано, но у формы из восьми полей, которую читают один раз при отправке, круг не нужен: значения и так лежат в DOM, а забирать их целиком вы умеете с части II — FormData. Здесь — про поля, которыми владеет браузер, про ref как способ дотянуться до узла и про то, где ref уместнее состояния.
Поле без пропса value React не трогает: он создаёт узел, а дальше текст в нём принадлежит браузеру — ровно как в проекте части II, где форма добавления задачи жила в разметке, а submit собирал её через FormData. Такое поле называют неуправляемым. Обработчик отправки при этом почти дословно повторяет код из статьи «Форма добавления и валидация»: preventDefault, new FormData(form), чтение. Предскажите, сколько раз напечатается «рисую», пока пример набирает текст и ставит галочку.
Одна строка «рисую» — при первой отрисовке. Набор текста до React не дошёл вовсе: нет состояния, нет сеттера, нечему перерисовываться. Всё, что вы знаете о FormData, работает без поправок: поле без name в него не попадёт, отмеченный чекбокс без value даст "on", неотмеченный — null. И e.currentTarget здесь именно HTMLFormElement, а не EventTarget, — почему, разбирала статья про всплытие.
Единственное, что добавляет TypeScript, — честный тип результата. В части II fd.get("title") просто возвращал строку; компилятор с этим не согласен, и у него две причины.
Тип fd.get() — FormDataEntryValue | null, где FormDataEntryValue — это string | File. Первая диагностика про null: имени может не оказаться в форме. Вторая — про File: у файла нет trim, а по одному имени компилятор не отличит текстовое поле от type="file". Восклицательный знак снимает только первую и ничего не знает о второй. Лечится это сужением из части III — одной проверкой typeof.
Object.fromEntries(fd) даёт объект типа { [k: string]: FormDataEntryValue } — с той же парой string | File в каждом поле и с той же потерей повторов, о которой предупреждала статья про FormData. Типы удобнее не стали, а getAll пропал.
В HTML-строках части II начальное значение задавал атрибут value="Рико": браузер показывал его и дальше не вмешивался. В JSX тот же value означает другое — «этим полем управляю я». Самый частый вопрос новичка в React звучит как «почему в моём input нельзя печатать», и ответ именно здесь. Предскажите, что окажется в двух полях после того, как пример напечатает в каждое слово «Гость».
Первое поле не поддаётся: сработала та самая «заморозка» из статьи «Управляемые поля: состояние вместо DOM» — React сверил узел с value и вернул «Рико». Нового здесь только то, что состояния нет вовсе: менять нечего, и поле обязано показывать пропс всегда. Первой строкой вывода React ещё и советует defaultValue: песочница собрана отладочной, и это предупреждение здесь видно — в боевой сборке его нет.
defaultValue — это тот самый атрибут value из части II под другим именем: React ставит его при создании узла и больше не вспоминает о нём ни на одной перерисовке. У чекбоксов и радиокнопок по той же логике есть defaultChecked; у select начальный выбор задают через defaultValue на самом select, а не через selected на option; у textarea текст кладут в defaultValue, а не между тегами.
| Управляемое поле | Неуправляемое поле | |
|---|---|---|
| Где живёт значение | в состоянии компонента | в DOM-узле |
| Начальное значение | useState("…") | defaultValue, defaultChecked |
| Как прочитать | из переменной состояния | FormData при отправке или ref |
| Перерисовка при вводе | на каждый символ | нет |
| Как сбросить | сеттером | form.reset() |
| Когда брать | значение нужно во время ввода: подсказки, счётчик символов, зависимые поля | значение нужно один раз — при отправке |
Смешивать можно: в одной форме поле поиска бывает управляемым, потому что по нему фильтруется список, а остальные семь — неуправляемыми. Решение принимается по полю, а не по форме.
Иногда до неуправляемого поля нужно добраться не в момент отправки, а по своему поводу: поставить фокус, прокрутить к нему, прочитать значение по клику на другую кнопку. В части II вы бы взяли querySelector. В React так тоже можно, но это поиск по всей странице, который сломается, как только рядом появится второй такой же компонент, — вспомните два счётчика из статьи про useState. Свой способ у React называется ref.
React.useRef создаёт объект с единственным полем current. Если передать этот объект элементу через пропс ref, React после коммита положит в current настоящий DOM-узел. Предскажите, что напечатает первая строка — та, что в теле компонента.
null. Это не ошибка: пока функция компонента работает, никакого <input> на странице ещё нет — он появится на стадии коммита (статья «Триггер, рендер, коммит»). Узел лежит в current только там, где код выполняется после коммита: в обработчиках и, забегая вперёд, в эффектах. Поэтому и тип у current — HTMLInputElement | null, а обращение идёт через ?..Компилятор одну половину договора помнит за вас, а вторую — что ref должен стоять на элементе нужного типа — проверяет через типы пропсов, тем же приёмом, что и title={2024} в статье про пропсы.
Две диагностики. Первая — про null на строке 6. Вторая — про перепутанный ref: RefObject<HTMLDivElement | null> нельзя отдать в ref у <input>, потому что у HTMLInputElement пятьдесят восемь свойств, которых у HTMLDivElement нет, и структурная типизация из части III это видит. А вот наоборот — ref от поля в div — компилятор пропускает молча: сверх HTMLElement у HTMLDivElement есть один устаревший align, и он есть и у поля. Структурная типизация проверяет состав, а не имя, поэтому вторую половину перепутанных ref не поймает никто: она вылезет на первом же обращении к полю, которого у настоящего узла нет. Без параметра типа — useRef(null) — получился бы RefObject<null>, в который узел не положить вовсе, так что <HTMLInputElement> здесь не украшение.
ref — тоже проп, но особый: React не отдаёт его вашему компоненту как обычное поле props, а кладёт в него узел сам. Как принять ref в своём компоненте и что такое ref-функция — статья «Ссылки на узлы: useRef и ref как пропс» в главе про эффекты.
В current можно класть не только узлы. Ref — это ячейка, которую React хранит рядом с состоянием компонента, но за которой не следит. Статья про useState начиналась с обычной переменной let clicks, которая не переживала перерисовку и не вызывала её. Вот та же сцена с ref; предскажите, что окажется в абзаце после двух кликов и что — после перерисовки.
Половина беды из статьи про useState вылечена: после перерисовки в ref по-прежнему двойка, а не ноль. Вторая половина осталась как была: два клика ничего не перерисовали, и на экране до чужой перерисовки висел ноль. Это не недоработка, а назначение. Ref — для значений, которые в разметке не участвуют, и поэтому перерисовывать из-за них нечего.
let в теле компонента | useRef | useState | |
|---|---|---|---|
| Переживает перерисовку | нет | да | да |
| Вызывает перерисовку | нет | нет | да |
| Как менять | присваиванием | ref.current = … | только сеттером |
| Что хранить | то, что нужно до конца этого рендера | узлы, номера таймеров, флаги «уже отправляли» | всё, что видно на экране |
Читать и менять current во время рендера не стоит — React не обещает, что заметит: пример выше делает это нарочно, чтобы показать, чего ждать не надо. Место для ref — обработчики и эффекты.
Кнопка «сохранить», по которой жмут трижды подряд, должна сохранить один раз — когда жать перестали. Номер таймера нужно помнить между кликами, но показывать его некому. В состоянии он вызывал бы перерисовку на каждый клик, в let — терялся бы вместе с ней. Предскажите, сколько раз напечатается «рисую».
Неуправляемая форма не означает «без состояния». Текст ошибки, статус «сохраняем…», счётчик добавленных задач — всё это видно на экране, а значит, живёт в состоянии. Состояние меняется редко — при отправке, — а поля продолжают принадлежать DOM. Вопрос на прогноз: когда сеттер ошибки перерисует форму, уцелеет ли то, что человек уже набрал во втором поле?
Заметка уцелела. Перерисовка не пересоздаёт узлы — коммит меняет в DOM только различия, а у поля без пропса value различий нет: React ему ничего не выставлял и выставлять не собирается. Это ровно та ситуация, которая ломалась в части II: там render() собирал innerHTML заново, и всё набранное в форме исчезало при каждой перерисовке доски. Здесь состояние ошибки и текст в полях друг другу не мешают.
Очистка после удачной отправки — тоже дело DOM: form.reset() возвращает каждому полю его defaultValue. Сеттер для этого не нужен, потому что значений полей в состоянии никогда и не было.
Правило выбора одной строкой: если значение участвует в разметке — состояние; если нужно только в момент действия — DOM и FormData; если нужно между действиями, но на экране не видно — ref.
В части II атрибут action был адресом, куда браузер уносил форму. В React 19 в него можно положить функцию — и тогда цепочка «перехватить submit, отменить, собрать FormData» сворачивается до одного параметра: React сам отменит отправку, сам соберёт данные и вызовет вашу функцию с готовым FormData. Предскажите, что окажется в поле после того, как действие отработает.
Поле опустело само: после того как действие завершилось, React сбрасывает неуправляемые поля формы — тот же reset(), только за вас. Управляемые поля он не тронет, у них хозяин другой. Тип у пропса подсказывает и остальное: action принимает функцию вида (formData: FormData) => void | Promise<void>, то есть действие может быть асинхронным, и React дождётся его.
Это первая половина большой темы. Та же функция, объявленная на сервере, становится Server Action — форма уходит в обработчик без fetch и вообще без кода на клиенте; статусом «отправляем…» и результатом действия заведуют useActionState и useFormStatus. Всё это — глава «Изменение данных». Здесь важно другое: неуправляемая форма и FormData — не обходной путь, а тот способ работы с формами, на который React делает ставку. Но и у этого способа есть предел: когда полей десятки и проверка идёт на лету, форму отдают библиотеке. Какую взять и как подключить к ней схему проверки через общий интерфейс, разобрано на полке React и Next.
- Неуправляемое поле — то, у которого нет пропса value: значение живёт в DOM, и набор текста не вызывает ни одной перерисовки.
- Читать такую форму — в onSubmit через new FormData(e.currentTarget); get даёт string | File | null, и его сужают проверкой typeof, как в части III.
- value без onChange замораживает поле — React охраняет значение; начальное значение задают через defaultValue и defaultChecked.
- React.useRef<HTMLInputElement>(null) вместе с ref={…} даёт узел после коммита — до него в current лежит null, и компилятор об этом помнит.
- Ref — память, которая переживает перерисовку, но не вызывает её: узлы, номера таймеров и всё, что не участвует в разметке; то, что видно на экране, — состояние.
- Ошибки и статусы — в состоянии, символы — в DOM; а <form action={fn}> в React 19 отдаёт FormData сразу и сбрасывает форму сам.
1/4В JSX написано <input value="Рико" /> — без onChange и без состояния. Что произойдёт, когда человек попробует набрать в поле букву?
2/4Какой тип у выражения fd.get("title"), где fd — это FormData?
3/4Счётчик кликов лежит в React.useRef(0), обработчик делает clicks.current += 1, а компонент выводит {clicks.current}. Что покажет экран после двух кликов?
4/4Форма с неуправляемыми полями перерисовалась из-за setError. Что стало с текстом, который человек уже набрал в полях?
Проверка при отправке
Поля остаются неуправляемыми, а в состоянии живут только ошибки. Соберите форму через FormData, проверьте логин (не пустой после trim) и почту (содержит @), положите найденные ошибки в состояние и покажите каждую под своим полем. Помните про тип fd.get().
Значения сужаем до string прямо в условиях: не строка — тоже ошибка. Один сеттер в конце — одна перерисовка, и она не трогает набранный текст.
В разметке под каждым полем — условный вывод: {errors.login && <p>{errors.login}</p>}. Пустой объект после удачной проверки тоже стоит положить в состояние: иначе старые ошибки останутся на экране.
Секундомер
Секунды видны на экране — это состояние. Номер интервала на экране не нужен, но нужен между кликами, чтобы «стоп» знал, что останавливать, а повторный «старт» не запустил второй интервал поверх первого. Для примера тик — 100 мс.
Номер интервала — в ref: он переживает перерисовки от setSeconds, но сам их не вызывает. Обновитель (s) => s + 1 нужен, потому что колбэк интервала создан в первом рендере и своё seconds помнит нулём.
В состоянии номер интервала был бы снимком: stop из старого рендера мог бы остановить не тот таймер. В let он не дожил бы до следующего клика. Что делать с интервалом, если компонент исчезнет со страницы, — тема главы про эффекты.
Сбросить и вернуть курсор
Кнопка «сбросить» должна вернуть всем полям начальные значения и поставить фокус в первое поле. Значений полей в состоянии нет — и не должно появиться.
Два ref: на форму и на первое поле. Сброс — родной reset() формы, он возвращает каждому полю его defaultValue; фокус — focus() у узла.
Сброс можно отдать и браузеру целиком — <button type="reset"> делает то же без единой строки кода; тогда ref останется только один, ради фокуса. Что нельзя — заводить состояние на каждое поле ради кнопки, которая нажимается раз в день.