Формы спрашивают почти всегда: контролируемые и неконтролируемые поля, валидация, библиотеки. В React 19 добавились actions, и про них теперь тоже спрашивают.
Контролируемое поле
Значение живёт в состоянии React, поле лишь показывает его:
const [email, setEmail] = useState('');
<input value={email} onChange={(e) => setEmail(e.target.value)} />
Плюсы: значение всегда под рукой, можно форматировать на лету, блокировать кнопку, валидировать при вводе. Минус: каждый символ вызывает рендер компонента с формой.
Детали, на которых ловят:
valueбезonChangeделает поле только для чтения, React пишет предупреждение. Если так и задумано, укажитеreadOnly.onChangeв React срабатывает на каждый ввод символа (как нативное событиеinput), а не при потере фокуса, как нативныйchange.- Значение поля всегда строка, даже у
type="number". Число берут черезNumber(e.target.value)илиe.target.valueAsNumber. - У чекбокса управляют
checked, у<select>пишутvalueна самомselect, у<textarea>тожеvalue, а не дочерний текст. - Если
valueсначалаundefined, а потом строка, React предупредит о переходе из неконтролируемого в контролируемое. Начальное значение задавайте''.
Неконтролируемое поле
Значение живёт в DOM, React его не хранит. Начальное значение задают defaultValue (defaultChecked у чекбокса), читают при отправке через FormData или ref:
function Signup() {
function handleSubmit(e) {
e.preventDefault();
const data = new FormData(e.currentTarget);
console.log(data.get('email'));
}
return (
<form onSubmit={handleSubmit}>
<input name="email" defaultValue="" />
<button>Отправить</button>
</form>
);
}
Меньше рендеров и кода. <input type="file"> всегда неконтролируемый.
Actions в React 19
В action формы можно передать функцию. React вызовет её с FormData, сам отменит перезагрузку страницы, а после успешного выполнения сбросит неконтролируемые поля формы.
<form action={async (formData) => { await save(formData.get('title')); }}>
<input name="title" />
<button>Сохранить</button>
</form>
Функция-action выполняется внутри перехода (transition), поэтому может быть асинхронной.
useActionState
Хранит результат последнего action и флаг ожидания:
import { useActionState } from 'react';
async function subscribe(prevState, formData) {
const email = formData.get('email');
if (!email.includes('@')) return { error: 'Неверный email' };
await api.subscribe(email);
return { ok: true };
}
function Form() {
const [state, formAction, isPending] = useActionState(subscribe, {});
return (
<form action={formAction}>
<input name="email" />
<button disabled={isPending}>Подписаться</button>
{state.error && <p>{state.error}</p>}
</form>
);
}
Обратите внимание: функция получает сначала предыдущее состояние, потом FormData. Возвращённое значение становится новым state.
useFormStatus
Хук из react-dom возвращает { pending, data, method, action } родительской формы. Поэтому вызывать его нужно в компоненте, который рендерится внутри <form>, а не в том же компоненте, где форма объявлена:
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Сохраняю…' : 'Сохранить'}</button>;
}
Так кнопку можно переиспользовать в любой форме без пробрасывания isPending.
useOptimistic
Показывает ожидаемый результат до ответа сервера:
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(current, newTodo) => [...current, { ...newTodo, sending: true }],
);
async function action(formData) {
const todo = { title: formData.get('title') };
addOptimistic(todo); // интерфейс обновился сразу
await createTodo(todo); // после завершения action оптимистичное
} // значение заменится реальным todos
addOptimistic вызывают внутри action или startTransition. Когда переход завершится, React вернётся к настоящему todos: если запрос упал и todos не обновили, временная запись исчезнет сама.
Библиотеки
React Hook Form строится на неконтролируемых полях: register('email') вешает ref и обработчики, состояние формы не вызывает рендер на каждый символ. Валидацию описывают схемой (Zod, Yup) через резолвер. Для сложных форм с массивами полей (useFieldArray) и десятками полей это стандарт.
Formik исторически популярен, но построен на контролируемом состоянии, поэтому в больших формах даёт больше перерисовок, чем React Hook Form.
Валидация
- Нативные атрибуты (
required,type="email",minLength,pattern) работают бесплатно и доступны. - Клиентская валидация нужна для удобства, серверная обязательна: клиентскую можно обойти.
- Ошибку показывают рядом с полем и связывают через
aria-describedby, поле помечаютaria-invalid. - Валидировать при каждом символе раздражает: обычно проверяют при потере фокуса, а после первой ошибки при вводе.
Частые ошибки
valueбезonChangeи удивление, что поле «не печатает».useFormStatusв том же компоненте, где<form>:pendingвсегдаfalse.- Перепутанный порядок аргументов в функции для
useActionState. - Сравнение
e.target.value === 5у числового поля. - Доверие только клиентской валидации.
Что спросят на собесе
- Чем контролируемое поле отличается от неконтролируемого, когда что выбрать?
- Как работают
useActionStateиuseFormStatus? - Что такое оптимистичное обновление и как его сделать в React 19?
- Почему React Hook Form быстрее формы на
useState? - Где должна быть валидация и как показать ошибку доступно?