Ручне створення форм у 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}
/>
Полі типу radio в одній групі мають спільне значення name. Кожне з них позначається, коли його value збігається із поточним станом, а спільний обробник зберігає вибір у 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покриває більшість полів. - Числа надходять у вигляді рядків, поле типу checkbox використовує атрибут
checked, списки керуються елементомselect, а поля для файлів завжди залишаються неконтрольованими. - Перевірка на клієнтській стороні слугує користувачеві; лише перевірка на сервері захищає дані.
- Стан подання моделі визначається явно, і кнопка подання блокується під час виконання запитів.
- Додайте React Hook Form та Zod, коли кількість полів, спільні правила чи витрати на відображення роблять ручне керування станом складним.