Неуправляемые поля, ref и FormData

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

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

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

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

Поле без пропса value React не трогает: он создаёт узел, а дальше текст в нём принадлежит браузеру — ровно как в проекте части II, где форма добавления задачи жила в разметке, а submit собирал её через FormData. Такое поле называют неуправляемым. Обработчик отправки при этом почти дословно повторяет код из статьи «Форма добавления и валидация»: preventDefault, new FormData(form), чтение. Предскажите, сколько раз напечатается «рисую», пока пример набирает текст и ставит галочку.

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

Одна строка «рисую» — при первой отрисовке. Набор текста до React не дошёл вовсе: нет состояния, нет сеттера, нечему перерисовываться. Всё, что вы знаете о FormData, работает без поправок: поле без name в него не попадёт, отмеченный чекбокс без value даст "on", неотмеченный — null. И e.currentTarget здесь именно HTMLFormElement, а не EventTarget, — почему, разбирала статья про всплытие.

Единственное, что добавляет TypeScript, — честный тип результата. В части II fd.get("title") просто возвращал строку; компилятор с этим не согласен, и у него две причины.

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

Тип fd.get() — FormDataEntryValue | null, где FormDataEntryValue — это string | File. Первая диагностика про null: имени может не оказаться в форме. Вторая — про File: у файла нет trim, а по одному имени компилятор не отличит текстовое поле от type="file". Восклицательный знак снимает только первую и ничего не знает о второй. Лечится это сужением из части III — одной проверкой typeof.

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

Object.fromEntries(fd) даёт объект типа { [k: string]: FormDataEntryValue } — с той же парой string | File в каждом поле и с той же потерей повторов, о которой предупреждала статья про FormData. Типы удобнее не стали, а getAll пропал.

В HTML-строках части II начальное значение задавал атрибут value="Рико": браузер показывал его и дальше не вмешивался. В JSX тот же value означает другое — «этим полем управляю я». Самый частый вопрос новичка в React звучит как «почему в моём input нельзя печатать», и ответ именно здесь. Предскажите, что окажется в двух полях после того, как пример напечатает в каждое слово «Гость».

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

Первое поле не поддаётся: сработала та самая «заморозка» из статьи «Управляемые поля: состояние вместо 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-узел. Предскажите, что напечатает первая строка — та, что в теле компонента.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
В рендере — null. Это не ошибка: пока функция компонента работает, никакого <input> на странице ещё нет — он появится на стадии коммита (статья «Триггер, рендер, коммит»). Узел лежит в current только там, где код выполняется после коммита: в обработчиках и, забегая вперёд, в эффектах. Поэтому и тип у current — HTMLInputElement | null, а обращение идёт через ?..

Компилятор одну половину договора помнит за вас, а вторую — что ref должен стоять на элементе нужного типа — проверяет через типы пропсов, тем же приёмом, что и title={2024} в статье про пропсы.

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

Две диагностики. Первая — про 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; предскажите, что окажется в абзаце после двух кликов и что — после перерисовки.

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

Половина беды из статьи про useState вылечена: после перерисовки в ref по-прежнему двойка, а не ноль. Вторая половина осталась как была: два клика ничего не перерисовали, и на экране до чужой перерисовки висел ноль. Это не недоработка, а назначение. Ref — для значений, которые в разметке не участвуют, и поэтому перерисовывать из-за них нечего.

let в теле компонентаuseRefuseState
Переживает перерисовкунетдада
Вызывает перерисовкунетнетда
Как менятьприсваиваниемref.current = …только сеттером
Что хранитьто, что нужно до конца этого рендераузлы, номера таймеров, флаги «уже отправляли»всё, что видно на экране

Читать и менять current во время рендера не стоит — React не обещает, что заметит: пример выше делает это нарочно, чтобы показать, чего ждать не надо. Место для ref — обработчики и эффекты.

Кнопка «сохранить», по которой жмут трижды подряд, должна сохранить один раз — когда жать перестали. Номер таймера нужно помнить между кликами, но показывать его некому. В состоянии он вызывал бы перерисовку на каждый клик, в let — терялся бы вместе с ней. Предскажите, сколько раз напечатается «рисую».

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

Неуправляемая форма не означает «без состояния». Текст ошибки, статус «сохраняем…», счётчик добавленных задач — всё это видно на экране, а значит, живёт в состоянии. Состояние меняется редко — при отправке, — а поля продолжают принадлежать DOM. Вопрос на прогноз: когда сеттер ошибки перерисует форму, уцелеет ли то, что человек уже набрал во втором поле?

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

Заметка уцелела. Перерисовка не пересоздаёт узлы — коммит меняет в DOM только различия, а у поля без пропса value различий нет: React ему ничего не выставлял и выставлять не собирается. Это ровно та ситуация, которая ломалась в части II: там render() собирал innerHTML заново, и всё набранное в форме исчезало при каждой перерисовке доски. Здесь состояние ошибки и текст в полях друг другу не мешают.

Очистка после удачной отправки — тоже дело DOM: form.reset() возвращает каждому полю его defaultValue. Сеттер для этого не нужен, потому что значений полей в состоянии никогда и не было.

Правило выбора одной строкой: если значение участвует в разметке — состояние; если нужно только в момент действия — DOM и FormData; если нужно между действиями, но на экране не видно — ref.

В части II атрибут action был адресом, куда браузер уносил форму. В React 19 в него можно положить функцию — и тогда цепочка «перехватить submit, отменить, собрать FormData» сворачивается до одного параметра: React сам отменит отправку, сам соберёт данные и вызовет вашу функцию с готовым FormData. Предскажите, что окажется в поле после того, как действие отработает.

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

Поле опустело само: после того как действие завершилось, 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().

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

Значения сужаем до string прямо в условиях: не строка — тоже ошибка. Один сеттер в конце — одна перерисовка, и она не трогает набранный текст.

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

В разметке под каждым полем — условный вывод: {errors.login && <p>{errors.login}</p>}. Пустой объект после удачной проверки тоже стоит положить в состояние: иначе старые ошибки останутся на экране.

Секундомер

Секунды видны на экране — это состояние. Номер интервала на экране не нужен, но нужен между кликами, чтобы «стоп» знал, что останавливать, а повторный «старт» не запустил второй интервал поверх первого. Для примера тик — 100 мс.

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

Номер интервала — в ref: он переживает перерисовки от setSeconds, но сам их не вызывает. Обновитель (s) => s + 1 нужен, потому что колбэк интервала создан в первом рендере и своё seconds помнит нулём.

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

В состоянии номер интервала был бы снимком: stop из старого рендера мог бы остановить не тот таймер. В let он не дожил бы до следующего клика. Что делать с интервалом, если компонент исчезнет со страницы, — тема главы про эффекты.

Сбросить и вернуть курсор

Кнопка «сбросить» должна вернуть всем полям начальные значения и поставить фокус в первое поле. Значений полей в состоянии нет — и не должно появиться.

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

Два ref: на форму и на первое поле. Сброс — родной reset() формы, он возвращает каждому полю его defaultValue; фокус — focus() у узла.

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

Сброс можно отдать и браузеру целиком — <button type="reset"> делает то же без единой строки кода; тогда ref останется только один, ради фокуса. Что нельзя — заводить состояние на каждое поле ради кнопки, которая нажимается раз в день.