Разработка форм с использованием React вручную: управляемые поля ввода, проверка данных и состояния отправки
Узнайте, как работают формы в React: от контролируемых полей ввода и обработчиков для разных типов данных до проверки данных, состояний отправки, динамических полей, загрузки файлов и ситуаций, когда библиотека форм оправдывает себя.
Источники: React Forms: A Complete Guide from Basics to Production Patterns
Вход в систему, оформление заказа, поиск, настройки профиля, опросы: практически каждое приложение строится вокруг форм. В React форма означает управление значениями, проверкой данных, сообщениями об ошибках, отправкой данных, состоянием загрузки и ошибками сервера, добавлением полей во время выполнения программы, сбросом данных и оптимизацией производительности. В этом руководстве пошагово показано, как создавать такие формы — от управляемых полей ввода до полноценной формы регистрации без использования сторонних библиотек, а также когда целесообразно использовать React Hook Form и Zod.
Как форма работает в приложении React
Форма собирает информацию от пользователя, как на этом экране регистрации:
┌──────────────────────────────┐
│ Create Account │
│ │
│ Name [______________] │
│ Email [______________] │
│ Password [______________] │
│ │
│ [ Register ] │
└──────────────────────────────┘
React связывает эти поля ввода с логикой приложения, и практически каждая форма проходит через один и тот же жизненный цикл:
User Input
↓
Form State
↓
Validation
↓
Submit
↓
API Request
↓
Success / Error
Управляемые компоненты: состояние как источник истины
В управляемом компоненте значение поля ввода value берется из состояния React, а любые изменения записываются обратно через метод onChange.
import { useState } from "react";
function LoginForm() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
);
}
Установка value={email} означает, что React сам определяет, что будет отображаться в поле. Каждая нажатие клавиши проходит через этот цикл:
User types
↓
onChange fires
↓
setEmail()
↓
React state updates
↓
Component renders
↓
Input receives new value
Поскольку отображаемое значение всегда получается из состояния, состояние является единственным источником правды.
Название «управляемый» отражает тот факт, что поле ввода не контролирует свое собственное значение:
<input
value={email}
onChange={handleChange}
/>
Данные передаются от состояния к элементу, затем через ввод пользователя и обработчик, и снова обратно в состояние:
React State
│
▼
input value
│
▼
User types
│
▼
onChange
│
▼
React State
Один объект состояния для нескольких полей
Использование вызова useState для каждого поля приводит к плохой масштабируемости:
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
Более просто обслуживать один объект, ключом к которому является имя поля. В этой форме три поля находятся в одном объекте, они делит один метод handleChange, и данные записываются при отправке формы.
import { useState } from "react";
function RegisterForm() {
const [form, setForm] = useState({
name: "",
email: "",
password: "",
});
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const handleSubmit = (event) => {
event.preventDefault();
console.log(form);
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={form.name}
onChange={handleChange}
/>
<input
name="email"
value={form.email}
onChange={handleChange}
/>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
<button type="submit">
Register
</button>
</form>
);
}
Ключевой строкой является вычисляемое свойство [name]: value. Атрибут name каждого поля совпадает с ключом состояния, поэтому один обработчик обновляет то поле, которое было активировано.
Для текстовых полей обработчик считывает данные из элемента, связанного с событием:
<input
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
event.target — это само поле ввода, поэтому event.target.value содержит его текущий текст:
User types: Salim
event.target.value
↓
"Salim"
Правильная обработка каждого типа поля ввода
Формы содержат элементы для ввода текста, пароля, чисел, полей с флажками, радио-кнопок, выпадающих списков, текстовых полей большого размера, загрузки файлов, указания даты и диапазонов. Большинство из них работают по одному и тому же принципу, с небольшими важными отличиями.
Текст, пароль и числа
Поле текста связывает value с его ключом:
<input
type="text"
value={form.name}
onChange={handleChange}
/>
Поле пароля отличается только параметром type:
<input
type="password"
value={form.password}
onChange={handleChange}
/>
То же самое касается поля чисел:
<input
type="number"
value={form.age}
onChange={handleChange}
/>
Проблема в том, что event.target.value всегда является строкой даже при настройке type="number". В приведённом ниже фрагменте смешаны код и пояснения; суть в том, что получается значение вида "25", а не 25, и для преобразования требуется использовать функцию Number().
const age = event.target.value;
may produce:
"25" rather than: 25
If you need a number:
const age = Number(event.target.value);
Обратите внимание на пустые значения: Number("") возвращает 0, поэтому многие формы сохраняют исходную строку и преобразуют её только при проверке или отправке формы.
Для полей с флажком используется свойство checked
Для поля с флажком свяжите свойство checked с логическим значением и читайте значение через event.target.checked:
<input
type="checkbox"
checked={form.agree}
onChange={(event) => {
setForm((previous) => ({
...previous,
agree: event.target.checked,
}));
}}
/>
event.target.value для полей типа checkbox возвращает значение атрибута value (по умолчанию "on"), а не информацию о том, отмечено ли поле.
Группы элементов select, textarea и radio
Элемент select управляется через атрибут value; пустой первый вариант используется в качестве местохождения.
<select
name="role"
value={form.role}
onChange={handleChange}
>
<option value="">
Select role
</option>
<option value="frontend">
Frontend Developer
</option>
<option value="backend">
Backend Developer
</option>
</select>
В React элемент textarea рассматривается как обычное поле ввода, у которого есть свойство value вместо текста внутри элемента.
<textarea
name="message"
value={form.message}
onChange={handleChange}
/>
Ползунки выбора в одной группе имеют общее свойство name. Каждый из них отмечается, когда его значение совпадает со состоянием приложения, а общий обработчик сохраняет выбор в form.gender.
<label>
<input
type="radio"
name="gender"
value="male"
checked={form.gender === "male"}
onChange={handleChange}
/>
Male
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={form.gender === "female"}
onChange={handleChange}
/>
Female
</label>
Перехват процесса отправки формы
По умолчанию браузер сам отправляет форму и может перейти на другую страницу или перезагрузить её. В приложениях React обработка отправки осуществляется с помощью JavaScript, поэтому обработчик отправки начинается со строки event.preventDefault(), как в примере регистрации. Далее логика развивается по вашему усмотрению:
Submit
↓
preventDefault()
↓
Validate
↓
API
↓
Update UI
Проверка корректности перед запросом
Проверки корректности определяют, является ли введённый контент приемлемым. В первой версии ошибки собираются по ключам полей:
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
}
if (!form.password) {
errors.password = "Password is required";
}
return errors;
};
Обработчик отправки сначала проверяет данные, сохраняет ошибки и прерывает работу при их наличии:
const handleSubmit = (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Submit form
};
Ошибки хранятся в отдельном состоянии:
const [errors, setErrors] = useState({});
В определённый момент оно может содержать:
{
email: "Invalid email",
password: "Password must contain at least 8 characters"
}
Каждое поле отображает соответствующее сообщение условно:
{errors.email && (
<p>{errors.email}</p>
)}
Лучшая реализация функции validate принимает форму в качестве аргумента, что делает её чистой и поддающейся тестированию, а также проверяет формат и длину данных только при наличии поля:
function validate(form) {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
} else if (!form.email.includes("@")) {
errors.email = "Invalid email";
}
if (!form.password) {
errors.password = "Password is required";
} else if (form.password.length < 8) {
errors.password =
"Password must contain at least 8 characters";
}
return errors;
}
Обработчик вызывает его таким же образом:
const errors = validate(form);
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
}
Проверка includes("@") выявляет только очевидные опечатки; настоящим подтверждением работоспособности адреса является письмо с подтверждением.
Проверка на стороне клиента и сервера выполняют разные задачи
Проверка на стороне клиента происходит в браузере и обычно включает:
- Обязательные поля
- Формат электронной почты
- Минимальная длина пароля
- Подтверждение пароля
Она обеспечивает быструю обратную связь, улучшает пользовательский опыт и сокращает количество ненужных запросов, но не является барьером безопасности. Сервер должен повторить проверки и ответить на вопросы, которые браузер не может решить:
- Зарегистрирована ли эта почта уже?
- Имеет ли пользователь права на доступ?
- Валиден ли цена?
- Может ли этот пользователь выполнять данную операцию?
Безопасные приложения используют оба подхода. Никогда не пишите логику в таком виде:
if (clientValidationPassed) {
// Data is safe
}
Проверки браузера можно обойти путем редактирования страницы или прямого вызова API, поэтому бэкенд самостоятельно проверяет каждый запрос:
Request
↓
Authentication
↓
Authorization
↓
Validation
↓
Business Logic
↓
Database
Моделирование состояний отправки
Форма в производственной среде проходит через определённые состояния, которые должен отражать интерфейс:
Idle
↓
Submitting
↓
Success
or
Idle
↓
Submitting
↓
Error
Два элемента состояния отслеживают их:
const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState("");
Асинхронный обработчик устанавливает флаг, очищает старые ошибки сервера, ожидает запрос, записывает понятное сообщение при сбое и в любом случае сбрасывает флаг в блоке finally.
const handleSubmit = async (event) => {
event.preventDefault();
setIsSubmitting(true);
setServerError("");
try {
await createUser(form);
} catch (error) {
setServerError("Unable to create account");
} finally {
setIsSubmitting(false);
}
};
Отключение и переименование кнопки во время запроса предотвращает дублирование отправок:
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
Цикл жизни кнопки совпадает с циклом обработки запроса:
User clicks Submit
↓
Request starts
↓
Button disabled
↓
Request finishes
↓
Button enabled
Контролируемые и неконтролируемые поля ввода
При использовании контролируемого ввода состояние React хранит значение. В приведённом ниже блоке показана сокращённая версия, полный компонент и поток данных, разделённые строками из звёздочек.
<input
value={email}
onChange={handleChange}
/>
********************************************
function Form() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) =>
setEmail(event.target.value)
}
/>
);
}
********************************************
DOM event
↓
React state
↓
Render
↓
DOM value
При использовании неконтролируемого ввода DOM хранит значение, а React считывает его через ref, обычно при отправке формы, не сохраняя каждую нажатую клавишу:
const emailRef = useRef();
<input ref={emailRef} />
********************************************
function Form() {
const emailRef = useRef(null);
const handleSubmit = (event) => {
event.preventDefault();
console.log(emailRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} />
<button type="submit">
Submit
</button>
</form>
);
}
********************************************
DOM
↓
Input value
Используйте контролируемые вводы для мгновенной проверки, условного отображения интерфейса, вычисления производных значений, форматирования, управления состоянием через React или реализации пользовательских интеракций. Поскольку React знает значение, интерфейс может мгновенно реагировать:
{password.length < 8 && (
<p>Password is too short</p>
)}
Выбирайте неуправляемые поля ввода для простых форм, когда отслеживание состояния при каждом нажатии клавиши не приносит пользы, когда требуется меньше обновлений, или когда библиотека сама управляет ссылками на данные. Действия форм в React 19 также основаны на этой модели; см. асинхронные формы с useActionState и useOptimistic.
Когда библиотека для форм оправдывает себя
React Hook Form
Ручная настройка десятков полей — утомительно. Функция register из React Hook Form связывает поле ввода и задаёт для него правила, handleSubmit проверяет данные перед вызовом вашей функции обратного вызова, а formState.errors отображает сообщения об ошибках.
import { useForm } from "react-hook-form";
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("email", {
required: "Email is required",
})}
/>
{errors.email && (
<p>{errors.email.message}</p>
)}
<input
type="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message:
"Password must be at least 8 characters",
},
})}
/>
{errors.password && (
<p>{errors.password.message}</p>
)}
<button type="submit">
Login
</button>
</form>
);
}
Это упрощает регистрацию, валидацию, обработку ошибок, отправку данных, сброс настройек, работу с динамическими полями и состоянием формы. Поскольку по умолчанию регистрируются все поля, это позволяет избежать перерисовки всей формы при каждом нажатии клавиши, в отличие от примитивных форм с контролем.
Валидация схемы с помощью Zod
По мере увеличения количества правил разрозненные операторы if становятся менее удобными в использовании. Zod позволяет описать ожидаемую структуру данных в одном объявлении:
import { z } from "zod";
const userSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters"),
email: z
.string()
.email("Invalid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters"),
});
Часто в производственных приложениях библиотека используется для управления состоянием, а схема — для определения правил:
React Hook Form
+
Zod
↓
Form state + validation
Схема может быть представлена в таком компактном виде:
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
React Hook Form позволяет подключать схему через резолвер, что удобно для крупных форм, правила которых должны оставаться последовательными.
Динамические, условные и зависимые поля
Повторяющиеся группы
Пользователи часто добавляют записи по мере необходимости, например информацию об образовании:
Education
Degree [________]
College [________]
[ + Add Education ]
Храните их в виде массива объектов:
const [education, setEducation] = useState([
{
degree: "",
college: "",
},
]);
При добавлении строки в конец добавляется пустой объект:
setEducation((previous) => [
...previous,
{
degree: "",
college: "",
},
]);
Отображение осуществляется путем просмотра элементов массива:
{education.map((item, index) => (
<div key={index}>
<input
value={item.degree}
onChange={(event) => {
// update degree
}}
/>
<input
value={item.college}
onChange={(event) => {
// update college
}}
/>
</div>
))}
Индекс key является надежным только тогда, когда строки никогда не удаляются и не перестраиваются; в противном случае используйте стабильные идентификаторы, иначе текст с определенным типом может быть привязан к неверной строке.
Условные поля
Некоторые поля зависят от предыдущего выбора, например, данные компании для бизнес-аккаунта:
Account Type
○ Individual
○ Business
If the user chooses:
Business
show:
Company Name
GST Number
Company Address
Для этого используется условное выражение:
{form.accountType === "business" && (
<>
<input
name="companyName"
value={form.companyName}
onChange={handleChange}
/>
<input
name="gstNumber"
value={form.gstNumber}
onChange={handleChange}
/>
</>
)}
Если пользователь вернется назад, очистите или исключите скрытые значения, чтобы не отправлялись устаревшие данные.
Каскадные выборы
Страна, штат и город образуют цепочку:
Country
↓
State
↓
City
Изменение на более высоком уровне сбрасывает все элементы ниже него перед загрузкой новых опций:
Country changes
↓
Clear State
↓
Fetch states
↓
User selects State
↓
Fetch cities
Поскольку параметры поступают с сервера, TanStack Query хорошо сочетается с библиотекой форм для кэширования и управления состоянием загрузки.
Загрузка файлов
Полей ввода файлов нельзя управлять напрямую; считайте файл из event.target.files:
<input
type="file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
console.log(file);
}
}}
/>
Чтобы загрузить файл, отправьте тело запроса в формате FormData:
const formData = new FormData();
formData.append("name", form.name);
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData,
});
Не устанавливайте значение Content-Type: multipart/form-data вручную с помощью fetch и FormData; браузер сам генерирует этот заголовок с необходимыми маркерами разделения.
Поддержание высокой скорости работы крупных форм
Крупные, сложные формы создают нагрузку на примитивные формы, которые перерисовываются при каждом нажатии клавиши:
User types
↓
setState
↓
Render
↓
Large form tree
Полезные меры противодействия:
- Храните состояние локально, а не глобально без особой необходимости.
- Разделите форму на секции, чтобы при каждой изменении перерисовывалось меньше элементов:
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
- Используйте подходящую библиотеку, такую как React Hook Form.
- Избегайте тяжелых вычислений или вызовов API на каждый символ.
- Ограничьте выполнение операций с задержкой, например, проверки наличия имени пользователя:
Username input
↓
Wait 300ms
↓
Check availability
Полная форма без библиотек
В примере используется константа initialForm для сброса данных, отдельное состояние для ошибок полей, ошибок отправки и сервера, функция validate, а также асинхронная отправка данных в формате JSON; ответы, отличные от OK, считаются ошибками, данные сбрасываются при успехе, а флаг загрузки всегда сбрасывается. Метки связаны с полями с помощью атрибутов htmlFor и id.
import { useState } from "react";
const initialForm = {
name: "",
email: "",
password: "",
};
function RegisterForm() {
const [form, setForm] = useState(initialForm);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] =
useState(false);
const [serverError, setServerError] =
useState("");
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.includes("@")) {
errors.email = "Enter a valid email";
}
if (form.password.length < 8) {
errors.password =
"Password must be at least 8 characters";
}
return errors;
};
const handleSubmit = async (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
setServerError("");
setIsSubmitting(true);
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(form),
});
if (!response.ok) {
throw new Error("Registration failed");
}
setForm(initialForm);
} catch (error) {
setServerError(
"Unable to create your account."
);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">
Name
</label>
<input
id="name"
name="name"
value={form.name}
onChange={handleChange}
/>
{errors.name && (
<p>{errors.name}</p>
)}
</div>
<div>
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
/>
{errors.email && (
<p>{errors.email}</p>
)}
</div>
<div>
<label htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
{errors.password && (
<p>{errors.password}</p>
)}
</div>
{serverError && (
<p>{serverError}</p>
)}
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
</form>
);
}
export default RegisterForm;
Для производственной среды также установите атрибут aria-invalid на невалидные поля и свяжите сообщения с помощью атрибута aria-describedby.
Общий порядок действий
Всё вышеперечисленное вписывается в одну дерево решений: от ввода данных через проверку до обработки ошибок полей или вызова API с ветками для успешного и неудачного результата:
React Form
│
▼
User Input
│
▼
Form State
│
▼
Validation
/ \
Valid Invalid
│ │
▼ ▼
Submit Show Errors
│
▼
API Call
/ \
Success Error
│ │
▼ ▼
Reset/Form Show Server
Success UI Error
Основные выводы
- Контролируемый ввод делает состояние источником правды; один объект вместе с обработчиком вида
[name]: valueпокрывает большинство полей. - Числа поступают в виде строк, ползунки используют атрибут
checked, списки управляются через элементselect, а поля для загрузки файлов всегда остаются неконтролируемыми. - Проверка на стороне клиента направлена на удобство пользователя; только проверка на сервере защищает данные.
- Состояние отправки модели указывается явно, и кнопка отправки отключается во время запросов.
- Используйте React Hook Form и Zod, когда количество полей, общие правила или затраты на отрисовку делают ручное управление состоянием сложным.