Controlled-инпут берёт value из стейта React и сообщает о каждом изменении через onChange, поэтому единственным источником правды остаётся стейт. Uncontrolled-инпут хранит значение сам, в DOM: вы задаёте defaultValue и читаете значение, когда оно нужно, через ref или FormData.
Controlled: нужен, когда UI реагирует на каждое нажатие клавиши: живая валидация, форматирование телефона, кнопка неактивна, пока форма невалидна, поля зависят друг от друга.
Uncontrolled: меньше кода и нет ререндера на каждый символ; достаточно, если значения нужны только при сабмите. На нём строятся и form actions из React 19, и React Hook Form.
Предупреждение: value без onChange даёт поле только для чтения, и React об этом предупреждает. Если нужно только начальное значение, используйте defaultValue.
Не смешивайте: если один инпут переключается между режимами (например, value меняется с undefined на строку), появляется предупреждение «changing an uncontrolled input to be controlled».
ReactMiddle
Формы и actions
Контролируемые и неконтролируемые поля, React Hook Form, валидация через Zod, actions в React 19, useActionState, useFormStatus, useOptimistic
Закрыто вопросов
0 / 21
- 0Повторить
- 5Часто спрашивают
Вопросы
Дайте каждому полю name, обработайте onSubmit, вызовите e.preventDefault() и создайте new FormData(e.currentTarget). Значения уже лежат в браузере, а FormData собирает их, включая чекбоксы, селекты и файлы.
Object.fromEntries(formData) превращает их в обычный объект; для полей с несколькими значениями нужен formData.getAll('tags').
Каждое значение приходит строкой (или File), поэтому числа и булевы значения нужно преобразовать, лучше схемой вроде Zod.
Неотмеченного чекбокса в FormData просто нет.
В React 19 preventDefault можно не писать: передайте функцию в <form action={fn}>, и React сам вызовет её с FormData.
У каждого контрола свой проп для значения: чекбокс использует checked и читает e.target.checked, радиокнопка получает checked={value === option}, а <select> принимает value на самом <select>, а не selected на <option>.
Чекбокс: e.target.value у чекбокса равен статической строке "on"; это классический баг, когда копируют код текстового инпута.
Группа радиокнопок: у всех кнопок общий name и одна переменная стейта; каждая сравнивает её со своим вариантом.
Select: value на <select> работает так же, как у инпута; с multiple он принимает массив строк.
Числа: e.target.value всегда строка; используйте e.target.valueAsNumber или преобразуйте значение.
Стейт начинается с undefined, поэтому на первом рендере у инпута нет value и React считает его uncontrolled; после первого символа value становится строкой, и инпут переключается в controlled. Инициализируйте стейт пустой строкой.
React определяет режим по тому, равен ли value undefined (или null), поэтому useState<string>() незаметно создаёт uncontrolled-поле.
То же самое случается с данными, которые приходят позже: value={user?.name} равен undefined, пока запрос не завершился. Пишите value={user?.name ?? ''} или рендерите форму, только когда данные уже есть.
Смена режима посреди жизни компонента не просто шум в консоли: React перестаёт последовательно управлять значением в DOM, и сброс поля может не сработать.
Из соображений безопасности выбрать файл может только пользователь: скрипт не может задать value у поля выбора файла (разве что очистить его), поэтому React нечего контролировать. Выбор читают из e.target.files в onChange, превью показывают через URL.createObjectURL, а загружают файл внутри FormData.
Чтение: e.target.files имеет тип FileList или null, это не массив; берите files[0] или Array.from(files) при multiple.
Превью: URL.createObjectURL(file) возвращает blob: URL для <img src>, ничего не загружая на сервер. Отзывайте его, когда превью меняется, и при размонтировании, иначе ссылка на каждый выбранный файл живёт до закрытия страницы.
Загрузка: new FormData(form) включает File; отправляйте его телом fetch без Content-Type: браузер сам ставит multipart/form-data с boundary, а заголовок, написанный вручную, ломает загрузку.
Повторный выбор того же файла не вызывает событие change, потому что значение не изменилось; очищайте поле после обработки (input.value = '' или form.reset()).
Проверяйте дважды: accept только фильтрует диалог выбора, поэтому проверяйте file.type и file.size перед загрузкой и ещё раз на сервере.
Валидируйте на клиенте ради быстрого фидбэка и всегда повторно на сервере, потому что клиентские проверки можно обойти. Хороший вариант по умолчанию: сначала проверять при сабмите, а поле, которое уже показало ошибку, перепроверять при каждом изменении, чтобы не ругать пользователя, пока он ещё печатает.
При сабмите: меньше всего шума; именно так работает React Hook Form по умолчанию, mode: 'onSubmit' с reValidateMode: 'onChange'.
При потере фокуса (blur): хорошо для длинных форм; пользователь узнаёт о проблеме, когда уходит с поля.
При изменении: только для проверок, которые полезны вживую, вроде индикатора сложности пароля или проверки занятости логина (с debounce).
Нативные ограничения: required, type="email", minLength и pattern работают без JavaScript и блокируют сабмит; noValidate на форме отключает браузерный UI, когда вы рисуете свои сообщения.
Одна схема: опишите правила один раз (Zod) и запускайте ту же схему в браузере и на сервере, чтобы они не разошлись.
Серверные ошибки: некоторые правила знает только сервер (email уже занят); привязывайте их к полям, а не показывайте общий тост.
React Hook Form регистрирует инпуты как uncontrolled и читает их значения из DOM через ref, поэтому ввод текста не ререндерит компонент формы. Сверху он даёт то, что в самописных формах изобретают заново: валидацию, ошибки, отслеживание dirty и touched, состояние сабмита и массивы полей.
**register('email', rules)**: возвращает name, ref, onChange и onBlur, которые разворачиваются на нативный инпут.
**handleSubmit(onValid)**: валидирует форму и затем вызывает вашу функцию с типизированными значениями; preventDefault тоже вызывает он.
**formState**: это прокси: компонент ререндерится только из-за тех частей, которые читает (errors, isSubmitting, isDirty), поэтому читайте только то, что показываете.
Подписки: useWatch или watch('field') подписываются на одно поле, а не на всю форму.
Цена: ещё одна зависимость и своя модель мышления. Форме из трёх полей без правил он не нужен.
Опишите форму схемой Zod, передайте zodResolver(schema) в useForm и выведите тип TypeScript через z.infer. Тогда правила, тексты ошибок и тип живут в одном месте, а та же схема может проверять запрос на сервере.
Один источник правды: нет вручную написанного type, который расходится с правилами.
Правила между полями: .refine() с path вешает ошибку вроде «пароли не совпадают» на нужное поле.
Преобразования: строки из инпутов можно обрезать или приводить к типу в схеме, а не в каждом обработчике.
Переиспользование на сервере: вызовите schema.safeParse(data) в API-роуте или server function; он возвращает { success, data, error } и не бросает исключение.
Версии: актуальны Zod 4 и @hookform/resolvers v5; в Zod 4 появились форматы верхнего уровня вроде z.email().
Controller оборачивает controlled-компонент без нативного инпута, например date picker, react-select или компонент MUI, и связывает его value и onChange с формой. register работает только там, где можно повесить ref и DOM-события на настоящий инпут.
Проп render получает field (value, onChange, onBlur, name, ref) и fieldState (error, isTouched).
Передайте control из useForm; useController делает то же самое в виде хука, когда вы пишете свои компоненты полей.
Свой инпут, который пробрасывает ref в нативный <input>, может и дальше работать через register. В React 19 ref стал обычным пропом, поэтому forwardRef не нужен.
Controlled-поле ререндерится при каждом изменении; такова цена API стороннего компонента.
Используйте useFieldArray({ control, name }): он возвращает fields для рендера и append, remove, insert, move для изменения списка, а значения, ошибки и фокус остаются привязаны к нужной строке. Рендерьте каждую строку с key={field.id}, никогда не с индексом.
**Почему field.id**: после удаления второй строки все индексы после неё сдвигаются, и с key={index} React переиспользует не тот DOM-инпут, поэтому значения прыгают между строками.
Имена: вложенные поля регистрируются по пути, items.${index}.name; индекс в имени допустим, стабильным должен быть только key.
Значения по умолчанию: начальные строки передаются в defaultValues; append ждёт полный объект строки.
Валидация: схема массива в Zod (z.array(item).min(1)) проверяет и сами строки, и длину списка.
useForm читает defaultValues один раз, на первом рендере, когда user ещё undefined, поэтому форма остаётся с пустыми строками и после того, как запрос завершился. Рендерите форму только тогда, когда данные есть, или передавайте их через опцию values или reset.
React Hook Form кэширует defaultValues так же, как работают useState(initial) и defaultValue у поля: более позднее значение до полей не доходит.
Исправление 1 (в примере кода): дочерний компонент монтируется, только когда user загружен, поэтому его useForm сразу получает настоящие значения, а пустой формы не бывает вовсе; key={user.id} перемонтирует её для другого пользователя.
Исправление 2: useForm({ values }) синхронизирует форму при каждом изменении данных. Тогда фоновый перезапрос перезапишет то, что пользователь сейчас вводит, если не добавить resetOptions: { keepDirtyValues: true }, который сохраняет изменённые поля.
Исправление 3: reset(user) в эффекте, когда пришли данные; это более старая идиома с тем же риском перезаписи при каждом перезапросе.
Поздние значения по умолчанию ломают и isDirty, и «сбросить к сохранённому», потому что оба сравнивают с defaultValues.
Держите одно состояние формы для всех шагов выше компонентов шагов, чтобы значения переживали размонтирование шага, и перед переходом дальше проверяйте только поля текущего шага. При финальной отправке проверяйте всю схему целиком, а затем ещё раз на сервере.
Одна форма, много шагов: с React Hook Form в мастере один useForm плюс FormProvider; каждый шаг читает форму через useFormContext(). Значения размонтированных шагов сохраняются, потому что shouldUnregister по умолчанию равен false.
Проверка по шагам: trigger(stepFields[step]) проверяет только эти поля по общей схеме Zod; подходит и схема на каждый шаг, объединённая в конце.
Enter отправляет форму: нажатие Enter в поле на промежуточном шаге вызывает onSubmit, поэтому направляйте его в «далее», а не в финальную отправку.
Перезагрузка страницы: длинный мастер держит черновик в sessionStorage или в сторе с persist, а шаг в URL (?step=2), чтобы кнопка «Назад» и перезагрузка вели на тот же шаг.
Порядок шагов: шаг, открытый по URL, проверяет, что предыдущие шаги валидны, или отправляет пользователя на первый невалидный.
Без библиотеки: useReducer над шагами хранит значения; каждый шаг добавляет свои поля по нажатию «Далее».
Свяжите каждую ошибку с её полем: поставьте инпуту aria-invalid, направьте aria-describedby на элемент с текстом ошибки, дайте каждому полю настоящий <label>, а после неудачного сабмита переведите фокус на первое невалидное поле.
Лейблы: <label htmlFor> или оборачивающий <label>; placeholder не заменяет лейбл и исчезает, как только пользователь начинает печатать.
Id: useId() генерирует стабильные id для htmlFor и aria-describedby, которые к тому же совпадают при серверном и клиентском рендере.
Озвучивание: сводка с role="alert" зачитывается, когда появляется; не ставьте role="alert" на сообщение каждого поля, иначе при сабмите зачитается стена текста.
Фокус: React Hook Form по умолчанию фокусирует первую ошибку (shouldFocusError: true); в самописных формах вызывайте .focus() у ref поля.
Не полагайтесь на цвет: одной красной рамки недостаточно для людей с нарушением цветовосприятия; показывайте текст.
Action представляет собой асинхронную функцию, которую React выполняет внутри транзишена и сам отслеживает её pending-состояние, ошибки и оптимистичные обновления. Если передать её в <form action={fn}>, при сабмите она вызывается с FormData формы, без onSubmit и preventDefault.
Pending-состояние: пока action выполняется, useFormStatus и useActionState сообщают pending, а UI остаётся отзывчивым, потому что это транзишен.
Сброс: после успешного action React сбрасывает uncontrolled-поля формы; requestFormReset из react-dom сбрасывает её по требованию.
Ошибки: выброшенная ошибка уходит в ближайший error boundary; ожидаемые ошибки (валидацию) возвращайте как стейт через useActionState.
Server functions: во фреймворке с Server Components (Next.js) action может быть функцией с 'use server', и форма работает ещё до загрузки JavaScript.
Кнопки: <button formAction={fn}> позволяет дать одной форме несколько action.
useActionState(action, initialState) возвращает [state, formAction, isPending]. Ваш action получает предыдущий стейт и FormData и возвращает следующий стейт, поэтому ошибки валидации или сообщение об успехе становятся обычным стейтом, который форма рендерит.
Сигнатура: action имеет вид (prevState, formData) => newState | Promise<newState>; передайте formAction в <form action> или в formAction кнопки.
**isPending: true, пока action выполняется; по нему блокируют кнопку или показывают спиннер.
Очередь: несколько сабмитов выполняются по очереди, и каждый получает стейт, который вернул предыдущий.
Сохранить ввод при ошибке**: uncontrolled-поля сбрасываются после завершения action; чтобы не потерять введённое, верните отправленные значения в стейте и подставьте их в defaultValue.
Server functions: третий аргумент, permalink, позволяет форме работать до гидратации во фреймворке с Server Components.
История: в канареечных сборках React 19 хук назывался useFormState и жил в react-dom; его переименовали и перенесли в react.
useFormStatus вызывается в том же компоненте, который рендерит <form>, а он сообщает статус только родительской формы, поэтому здесь pending всегда false. Вынесите кнопку в отдельный компонент, который рендерится внутри формы.
useFormStatus (из react-dom) читает ближайший <form> выше по дереву, так же как потребитель контекста читает провайдер; форма из собственного вывода компонента выше него не находится.
Поэтому он хорошо подходит для переиспользуемых кнопок дизайн-системы: SubmitButton, который сам блокируется в любой форме, без пропсов.
Другой вариант: оставить всё в одном компоненте и взять isPending из useActionState.
Кроме pending, он возвращает data (отправленный FormData), method и action.
Поле title вернётся к «Draft», а поле tag сохранит «react». После успешного action React сбрасывает форму, и каждое uncontrolled-поле возвращается к своему defaultValue, а controlled-поле показывает то, что лежит в его стейте, а стейт никто не трогал.
Сброс происходит, только если action завершился без исключения; при ошибке поля сохраняют значения.
Это нативное поведение form.reset(), которое React выполняет после транзишена, поэтому поле возвращается именно к defaultValue.
Чтобы очистить и controlled-поле, вызовите setTag('') в action или сделайте поле uncontrolled.
Чтобы сохранить введённый title, используйте обычный обработчик onSubmit или верните значения в стейте useActionState и подставьте их обратно в defaultValue.
useOptimistic(state, updateFn) возвращает значение, равное state, пока вы не вызовете сеттер внутри action; после этого он сразу показывает ваше предполагаемое значение, пока этот action выполняется. Когда action заканчивается, значение возвращается к настоящему state, поэтому при ошибке догадка просто исчезает.
Откат бесплатно: восстанавливать снимок не нужно; если action бросил исключение или сервер отказал, оптимистичное значение отбрасывается по окончании транзишена.
Успех: обновите настоящий стейт (или перезапросите данные) внутри action; тогда оптимистичное значение заменяется реальными данными в том же коммите, без мерцания.
Только в транзишене: вызов сеттера вне action или startTransition выдаёт предупреждение, и значение сразу откатывается.
**updateFn(current, optimisticValue)**: это редьюсер, удобный, чтобы добавить в список ожидающий элемент; пометьте его (sending: true), чтобы рисовать полупрозрачным.
Ошибки: сообщайте пользователю, что не получилось; молчаливый откат выглядит так, будто клик проигнорировали.
Блокируйте второй сабмит, пока первый в процессе, и сделайте сервер устойчивым к дублям, которые всё равно прорвутся: отключайте кнопку по isPending или isSubmitting и отправляйте ключ идемпотентности, чтобы повторный запрос не создал второй заказ.
На клиенте
Блокируйте кнопку сабмита по флагу pending; иначе двойной клик или двойной Enter отправит два запроса раньше первого ререндера.
React 19 ставит сабмиты одного useActionState в очередь, поэтому они выполняются по одному, а не параллельно.
Для запросов, где побеждает последний (поиск, автосохранение), отменяйте предыдущий через AbortController или игнорируйте ответы, чей id запроса не последний.
На сервере
Генерируйте ключ идемпотентности (crypto.randomUUID()) при открытии формы и отправляйте его с запросом; сервер сохраняет ключ и на повтор возвращает первый результат.
Уникальное ограничение в базе данных остаётся последней линией защиты для операций создания.
Почему нужно и то и другое
Клиентская защита закрывает частый случай; но сеть может повторить запрос после таймаута, и это видит только сервер.
Обычно на каждое нажатие ререндерится вся форма, потому что значения всех полей лежат в одном объекте стейта наверху. Снимите профиль в React DevTools Profiler, а затем сузьте то, что ререндерится от одного символа: сделайте поля uncontrolled или опустите стейт и подписки к тому полю, которое меняется.
Сначала измерьте: «Highlight updates» и flame graph в Profiler показывают, какие компоненты ререндерятся и сколько времени занимает каждый.
Uncontrolled-поля: React Hook Form или обычный FormData вообще убирают ввод из стейта React.
Узкие подписки: в React Hook Form watch() без аргументов в корне ререндерит всю форму; используйте useWatch({ name }) в том единственном компоненте, которому нужно значение, и читайте только те ключи formState, которые рендерите.
Разбивка и мемоизация: вынесите каждую секцию в отдельный компонент; тогда React Compiler 1.0 или memo пропустят секции, у которых не поменялись пропсы.
Тяжёлый производный UI: оберните тяжёлое превью в useDeferredValue, чтобы ввод оставался отзывчивым, пока превью догоняет.
Очень длинные формы: разбейте на шаги или виртуализируйте длинные списки полей.
Actions с useActionState закрывают формы, правила которых проверяются при сабмите, особенно вместе с server functions, где форма работает даже до загрузки JavaScript. React Hook Form по-прежнему выигрывает в насыщенных клиентских формах: валидация отдельных полей по ходу ввода, отслеживание dirty, массивы полей и много controlled-виджетов.
Actions достаточно, когда
Форма короткая, проверяется при сабмите, и ошибки возвращает сервер (логин, рассылка, комментарий).
Вы используете фреймворк с Server Components и хотите progressive enhancement: <form> отправляется в server function ещё до гидратации.
React Hook Form, когда
Нужны ошибки при blur или изменении, isDirty для предупреждения о несохранённых изменениях, useFieldArray или шаги мастера с общим стейтом формы.
Форма настолько большая, что controlled-стейт на каждое поле начнёт тормозить.
Как их совместить
Они не исключают друг друга: валидируйте на клиенте через RHF и схему Zod, затем вызывайте server function внутри handleSubmit (обернув в startTransition) и проверяйте ещё раз той же схемой на сервере.