React Hook Form + Zod: типобезпечна валідація форм
React Hook Form + Zod: типобезпечна валідація форм
🔗 Офіційний референс: Документація React Hook Form та Zod Documentation
🎯 Мета лекції
- Зрозуміти філософію React Hook Form: мінімізація ререндерів та некерований підхід.
- Опанувати Zod як інструмент декларативного опису та валідації структур даних.
- Навчитися створювати типобезпечні форми з автоматичним виведенням TypeScript-типів із схем.
- Застосувати динамічні поля, асинхронну валідацію та інтеграцію з бекенд-валідацією.
🔑 Ключові терміни
- React Hook Form (RHF): бібліотека для керування формами через некеровані (uncontrolled) інпути та реф-підхід.
- Zod: бібліотека для декларативного опису схем даних із валідацією та автоматичним виведенням TypeScript-типів.
- Resolver: адаптер, що інтегрує зовнішню валідаційну бібліотеку (Zod, Yup, Joi) з React Hook Form.
useFieldArray: хук для роботи з динамічними списками полів (масиви об'єктів у формі).
Чому класичні підходи до форм у React є проблемними?
У попередніх лекціях ми працювали з формами через керовані компоненти (controlled components): значення кожного поля зберігається у стані React через useState, а при зміні поля викликається обробник onChange, що оновлює стан.
function SimpleForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log({ name, email });
};
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={(e) => setName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Відправити</button>
</form>
);
}
Цей підхід працює для простих форм, але має серйозні недоліки при масштабуванні:
Проблема 1: Надмірні ререндери
Кожна зміна символу у будь-якому полі викликає ререндер усього компонента форми та всіх його дочірніх компонентів. У формі з 20 полями це призводить до тисяч непотрібних рендерів при заповненні.
Проблема 2: Багатослівність коду валідації
Для кожного поля потрібно писати окремі змінні стану для помилок, логіку валідації, обробники onBlur та onChange. Код швидко стає громіздким та важко підтримуваним.
Проблема 3: Відсутність централізованої схеми
Логіка валідації розпорошена по всьому компоненту. Немає єдиного місця, де можна побачити структуру даних форми та всі правила валідації.
Проблема 4: Ручна синхронізація типів
TypeScript не знає структуру даних форми автоматично — доводиться вручну підтримувати синхронізацію між інтерфейсами типів і реальною структурою форми.
ref та валідує дані при сабміті або за потреби.Це радикально зменшує кількість ререндерів та покращує продуктивність, зберігаючи зручний API.Встановлення бібліотек
npm install react-hook-form zod @hookform/resolvers
pnpm add react-hook-form zod @hookform/resolvers
react-hook-form— ядро бібліотеки для роботи з формами.zod— бібліотека для декларативного опису та валідації схем даних.@hookform/resolvers— пакет з адаптерами (resolvers) для інтеграції React Hook Form з популярними валідаційними бібліотеками (Zod, Yup, Joi, Vest).
Перша форма: базова реєстрація користувача
Почнемо з найпростішої форми реєстрації із трьома полями: ім'я, email та пароль.
Крок 1: Визначення схеми валідації через Zod
import { z } from "zod";
// Zod-схема — єдине джерело правди для структури та валідації
const registrationSchema = z.object({
name: z
.string()
.min(2, "Ім'я має містити щонайменше 2 символи")
.max(50, "Ім'я не може перевищувати 50 символів"),
email: z
.string()
.email("Введіть коректну електронну адресу"),
password: z
.string()
.min(8, "Пароль має містити щонайменше 8 символів"),
});
// TypeScript-тип автоматично виводиться зі схеми!
type RegistrationFormData = z.infer<typeof registrationSchema>;
z.infer<typeof registrationSchema> аналізує схему Zod і генерує точний TypeScript-тип:type RegistrationFormData = {
name: string;
email: string;
password: string;
};
Крок 2: Створення форми з React Hook Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
});
const onSubmit = async (data: RegistrationFormData) => {
// Дані вже валідовані відповідно до схеми!
console.log("Валідні дані:", data);
// Імітація відправки на сервер
await new Promise((resolve) => setTimeout(resolve, 1000));
alert("Реєстрація успішна!");
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Ім'я</label>
<input id="name" {...register("name")} />
{errors.name && <p className="error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Пароль</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <p className="error">{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Відправка..." : "Зареєструватися"}
</button>
</form>
);
}
useForm<RegistrationFormData>: хук приймає дженерик-тип, що забезпечує типобезпеку при роботі з полями.resolver: zodResolver(registrationSchema): підключає Zod-схему як валідатор форми.register("name"): повертає об'єкт зref,name,onChange,onBlur— ці атрибути spread-оператор прив'язує до інпута.handleSubmit(onSubmit): обробник сабміту, що валідує дані перед викликомonSubmit. Якщо валідація не пройдена,onSubmitне викликається, натомістьerrorsзаповнюється помилками.errors: об'єкт з помилками валідації, структура якого відповідає структурі схеми.
Розширена валідація: підтвердження пароля та складні правила
Тепер додамо поле підтвердження пароля та перевірку, що обидва паролі збігаються.
const registrationSchema = z
.object({
name: z.string().min(2, "Ім'я має містити щонайменше 2 символи"),
email: z.string().email("Введіть коректну електронну адресу"),
password: z
.string()
.min(8, "Пароль має містити щонайменше 8 символів")
.regex(/[A-Z]/, "Пароль має містити хоча б одну велику літеру")
.regex(/[0-9]/, "Пароль має містити хоча б одну цифру"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Паролі не збігаються",
path: ["confirmPassword"], // Прив'язка помилки до конкретного поля
});
type RegistrationFormData = z.infer<typeof registrationSchema>;
.refine() дозволяє додавати міжполеві валідації (cross-field validations), коли правило залежить від кількох полів одночасно. Параметр path вказує, до якого поля прив'язати помилку у об'єкті errors.Альтернативно можна використовувати .superRefine() для ще більш складних сценаріїв з множинними помилками.Тепер додамо поле підтвердження пароля до форми:
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
mode: "onBlur", // Валідація при втраті фокуса
});
const onSubmit = async (data: RegistrationFormData) => {
console.log("Валідні дані:", data);
await new Promise((resolve) => setTimeout(resolve, 1000));
alert("Реєстрація успішна!");
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Ім'я</label>
<input id="name" {...register("name")} />
{errors.name && <p className="error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Пароль</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <p className="error">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="confirmPassword">Підтвердження пароля</label>
<input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p className="error">{errors.confirmPassword.message}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Відправка..." : "Зареєструватися"}
</button>
</form>
);
}
mode у useForm визначає, коли запускається валідація:"onSubmit"(за замовчуванням) — валідація лише при відправці форми."onBlur"— валідація при втраті фокуса полем."onChange"— валідація при кожній зміні значення (може бути ресурсномістким)."onTouched"— валідація після першогоblurі далі при кожній зміні."all"— валідація і приblur, і приchange.
"onBlur" або комбінація mode: "onBlur" + reValidateMode: "onChange".Значення за замовчуванням та скидання форми
React Hook Form дозволяє встановлювати початкові значення полів та скидати форму до цих значень:
function RegistrationForm() {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
},
});
const onSubmit = async (data: RegistrationFormData) => {
console.log("Валідні дані:", data);
await new Promise((resolve) => setTimeout(resolve, 1000));
alert("Реєстрація успішна!");
reset(); // Скидання форми до значень за замовчуванням
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Поля форми */}
<div>
<button type="submit" disabled={isSubmitting || !isDirty}>
{isSubmitting ? "Відправка..." : "Зареєструватися"}
</button>
<button type="button" onClick={() => reset()} disabled={!isDirty}>
Скинути
</button>
</div>
</form>
);
}
formState:isDirty—true, якщо користувач змінив хоча б одне поле.isValid—true, якщо всі поля валідні (потребуєmodeвідмінного від"onSubmit").isSubmitting—trueпід час виконання асинхронної функціїonSubmit.isSubmitted—trueпісля першої спроби відправлення форми.touchedFields— об'єкт з полями, які отримали фокус (onBlurвикликано).dirtyFields— об'єкт з полями, значення яких змінилося відносноdefaultValues.
Складні типи даних: числа, дати, чекбокси, радіо-кнопки
Zod підтримує всі TypeScript-типи, а React Hook Form автоматично перетворює рядкові значення інпутів на потрібні типи, якщо вказати valueAsNumber або valueAsDate.
Числові поля
const profileSchema = z.object({
name: z.string().min(2),
age: z
.number({ invalid_type_error: "Вік має бути числом" })
.int("Вік має бути цілим числом")
.min(16, "Мінімальний вік — 16 років")
.max(120, "Введіть коректний вік"),
});
type ProfileFormData = z.infer<typeof profileSchema>;
function ProfileForm() {
const { register, handleSubmit, formState: { errors } } = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
});
const onSubmit = (data: ProfileFormData) => {
console.log(typeof data.age); // "number", а не "string"!
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Ім'я</label>
<input id="name" {...register("name")} />
{errors.name && <p className="error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="age">Вік</label>
<input
id="age"
type="number"
{...register("age", { valueAsNumber: true })}
/>
{errors.age && <p className="error">{errors.age.message}</p>}
</div>
<button type="submit">Зберегти</button>
</form>
);
}
valueAsNumber: true значення інпута type="number" все одно буде рядком у JavaScript/TypeScript. React Hook Form перетворює його на число лише за явної вказівки.Альтернативно можна використовувати .coerce() у Zod-схемі:age: z.coerce.number().int().min(16).max(120)
Чекбокси та булеві значення
const termsSchema = z.object({
acceptTerms: z.literal(true, {
errorMap: () => ({ message: "Необхідно прийняти умови використання" }),
}),
subscribeNewsletter: z.boolean().optional(),
});
type TermsFormData = z.infer<typeof termsSchema>;
function TermsForm() {
const { register, handleSubmit, formState: { errors } } = useForm<TermsFormData>({
resolver: zodResolver(termsSchema),
});
const onSubmit = (data: TermsFormData) => {
console.log(data);
// { acceptTerms: true, subscribeNewsletter: false }
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>
<input type="checkbox" {...register("acceptTerms")} />
Я приймаю умови використання
</label>
{errors.acceptTerms && (
<p className="error">{errors.acceptTerms.message}</p>
)}
</div>
<div>
<label>
<input type="checkbox" {...register("subscribeNewsletter")} />
Підписатися на розсилку
</label>
</div>
<button type="submit">Продовжити</button>
</form>
);
}
z.literal(true) вимагає, щоб значення було самеtrue, а не просто truthy-значенням. Це ідеальний спосіб реалізувати обов'язкову згоду з правилами: якщо чекбокс не відмічено (значення false), валідація не пройде.Для необов'язкових чекбоксів використовуйте просто z.boolean().optional() або z.boolean().default(false).Радіо-кнопки та енуми
const feedbackSchema = z.object({
rating: z.enum(["excellent", "good", "neutral", "bad", "terrible"], {
errorMap: () => ({ message: "Оберіть оцінку" }),
}),
});
type FeedbackFormData = z.infer<typeof feedbackSchema>;
function FeedbackForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FeedbackFormData>({
resolver: zodResolver(feedbackSchema),
});
const onSubmit = (data: FeedbackFormData) => {
console.log(data.rating); // Тип: "excellent" | "good" | "neutral" | "bad" | "terrible"
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset>
<legend>Оцініть наш сервіс</legend>
<label>
<input type="radio" value="excellent" {...register("rating")} />
Чудово
</label>
<label>
<input type="radio" value="good" {...register("rating")} />
Добре
</label>
<label>
<input type="radio" value="neutral" {...register("rating")} />
Нейтрально
</label>
<label>
<input type="radio" value="bad" {...register("rating")} />
Погано
</label>
<label>
<input type="radio" value="terrible" {...register("rating")} />
Жахливо
</label>
{errors.rating && <p className="error">{errors.rating.message}</p>}
</fieldset>
<button type="submit">Відправити</button>
</form>
);
}
rating на етапі компіляції.Спостереження за значеннями полів: watch
Іноді потрібно реагувати на зміну значення поля в реальному часі — наприклад, показувати динамічний попередній перегляд, розраховувати залежні значення або умовно показувати/приховувати інші поля.
const orderSchema = z.object({
quantity: z.coerce.number().int().min(1),
pricePerUnit: z.coerce.number().positive(),
});
type OrderFormData = z.infer<typeof orderSchema>;
function OrderForm() {
const { register, watch, handleSubmit } = useForm<OrderFormData>({
resolver: zodResolver(orderSchema),
defaultValues: {
quantity: 1,
pricePerUnit: 100,
},
});
// Спостереження за полями
const quantity = watch("quantity");
const pricePerUnit = watch("pricePerUnit");
const totalPrice = (quantity || 0) * (pricePerUnit || 0);
const onSubmit = (data: OrderFormData) => {
console.log("Замовлення:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="quantity">Кількість</label>
<input
id="quantity"
type="number"
{...register("quantity", { valueAsNumber: true })}
/>
</div>
<div>
<label htmlFor="pricePerUnit">Ціна за одиницю (₴)</label>
<input
id="pricePerUnit"
type="number"
step="0.01"
{...register("pricePerUnit", { valueAsNumber: true })}
/>
</div>
<div>
<strong>Загальна вартість: {totalPrice.toFixed(2)} ₴</strong>
</div>
<button type="submit">Оформити замовлення</button>
</form>
);
}
watch()викликає ререндер компонента при кожній зміні спостережуваного поля. Це саме те, що ми хотіли уникнути, використовуючи React Hook Form!Проте ця поведінка виправдана лише для полів, значення яких реально відображається в UI (як у прикладі вище з динамічним обчисленням суми). Не викликайте watch() для полів, які не впливають на UI безпосередньо.Для складних обчислень можна використовувати useMemo:const totalPrice = useMemo(() => {
return (quantity || 0) * (pricePerUnit || 0);
}, [quantity, pricePerUnit]);
Умовна валідація: показ/приховування полів
Іноді структура форми залежить від значення інших полів. Наприклад, поле «Номер студентського квитка» має з'являтися лише якщо користувач обрав статус «Студент».
const registrationSchema = z
.object({
name: z.string().min(2),
userType: z.enum(["student", "professional"]),
studentId: z.string().optional(),
companyName: z.string().optional(),
})
.superRefine((data, ctx) => {
// Якщо обрано "student", studentId стає обов'язковим
if (data.userType === "student" && !data.studentId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Номер студентського квитка обов'язковий для студентів",
path: ["studentId"],
});
}
// Якщо обрано "professional", companyName стає обов'язковим
if (data.userType === "professional" && !data.companyName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Назва компанії обов'язкова для професіоналів",
path: ["companyName"],
});
}
});
type RegistrationFormData = z.infer<typeof registrationSchema>;
function ConditionalRegistrationForm() {
const { register, watch, handleSubmit, formState: { errors } } = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
defaultValues: {
name: "",
userType: "student",
},
});
const userType = watch("userType");
const onSubmit = (data: RegistrationFormData) => {
console.log("Дані реєстрації:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Ім'я</label>
<input id="name" {...register("name")} />
{errors.name && <p className="error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="userType">Тип користувача</label>
<select id="userType" {...register("userType")}>
<option value="student">Студент</option>
<option value="professional">Професіонал</option>
</select>
</div>
{userType === "student" && (
<div>
<label htmlFor="studentId">Номер студентського квитка</label>
<input id="studentId" {...register("studentId")} />
{errors.studentId && (
<p className="error">{errors.studentId.message}</p>
)}
</div>
)}
{userType === "professional" && (
<div>
<label htmlFor="companyName">Назва компанії</label>
<input id="companyName" {...register("companyName")} />
{errors.companyName && (
<p className="error">{errors.companyName.message}</p>
)}
</div>
)}
<button type="submit">Зареєструватися</button>
</form>
);
}
.superRefine() надає повний контроль над валідацією: ви отримуєте доступ до всіх даних форми та можете додавати помилки через ctx.addIssue() з точним вказанням шляху до поля.Це потужніший інструмент, ніж .refine(), який підходить для складних міжполевих залежностей.Динамічні поля: useFieldArray для масивів об'єктів
Часто форми містять динамічні списки — користувач може додавати або видаляти елементи на льоту. Наприклад, список номерів телефонів, освітніх закладів, досвіду роботи.
React Hook Form надає хук useFieldArray для керування такими масивами.
Приклад: форма з динамічним списком телефонів
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const contactSchema = z.object({
name: z.string().min(2),
phones: z
.array(
z.object({
number: z.string().regex(/^\+380\d{9}$/, "Формат: +380XXXXXXXXX"),
type: z.enum(["mobile", "home", "work"]),
})
)
.min(1, "Додайте хоча б один номер телефону"),
});
type ContactFormData = z.infer<typeof contactSchema>;
function ContactForm() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: "",
phones: [{ number: "", type: "mobile" }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "phones",
});
const onSubmit = (data: ContactFormData) => {
console.log("Контактні дані:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Ім'я</label>
<input id="name" {...register("name")} />
{errors.name && <p className="error">{errors.name.message}</p>}
</div>
<fieldset>
<legend>Телефони</legend>
{fields.map((field, index) => (
<div key={field.id} style={{ marginBottom: "1rem", border: "1px solid #ccc", padding: "0.5rem" }}>
<div>
<label htmlFor={`phones.${index}.number`}>Номер</label>
<input
id={`phones.${index}.number`}
{...register(`phones.${index}.number` as const)}
/>
{errors.phones?.[index]?.number && (
<p className="error">{errors.phones[index]?.number?.message}</p>
)}
</div>
<div>
<label htmlFor={`phones.${index}.type`}>Тип</label>
<select
id={`phones.${index}.type`}
{...register(`phones.${index}.type` as const)}
>
<option value="mobile">Мобільний</option>
<option value="home">Домашній</option>
<option value="work">Робочий</option>
</select>
</div>
<button type="button" onClick={() => remove(index)}>
Видалити
</button>
</div>
))}
<button
type="button"
onClick={() => append({ number: "", type: "mobile" })}
>
+ Додати телефон
</button>
{errors.phones && typeof errors.phones.message === "string" && (
<p className="error">{errors.phones.message}</p>
)}
</fieldset>
<button type="submit">Зберегти</button>
</form>
);
}
useFieldArray:control: об'єкт зuseForm, що передається уuseFieldArrayдля синхронізації стану.name: "phones": шлях до масиву у структурі форми.fields: масив елементів, кожен з яких має унікальнийid(використовуйте його якkeyу React).append(value): додає новий елемент у масив.remove(index): видаляє елемент за індексом.insert(index, value): вставляє елемент у конкретну позицію.swap(indexA, indexB): міняє місцями два елементи.move(from, to): переміщує елемент з однієї позиції в іншу.
field.id як key, а не індекс масиву. React Hook Form генерує унікальні ID для кожного елемента, що гарантує коректну роботу при видаленні або переміщенні елементів. Використання індексу як key призведе до проблем з фокусом та валідацією.Асинхронна валідація: перевірка унікальності email на сервері
Іноді валідація вимагає запиту до сервера — наприклад, перевірка, чи вже зареєстрований email у системі. React Hook Form підтримує асинхронну валідацію через параметр validate.
const registrationSchema = z.object({
name: z.string().min(2),
email: z.string().email("Введіть коректну електронну адресу"),
password: z.string().min(8),
});
type RegistrationFormData = z.infer<typeof registrationSchema>;
// Симуляція API для перевірки email
async function checkEmailAvailability(email: string): Promise<boolean> {
await new Promise((resolve) => setTimeout(resolve, 500));
// Симулюємо, що "admin@example.com" вже зайнятий
return email !== "admin@example.com";
}
function AsyncValidationForm() {
const {
register,
handleSubmit,
formState: { errors, isValidating },
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
mode: "onBlur",
});
const onSubmit = (data: RegistrationFormData) => {
console.log("Валідні дані:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Ім'я</label>
<input id="name" {...register("name")} />
{errors.name && <p className="error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
{...register("email", {
validate: async (value) => {
const isAvailable = await checkEmailAvailability(value);
return isAvailable || "Ця електронна адреса вже використовується";
},
})}
/>
{isValidating && <p className="info">Перевірка доступності...</p>}
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Пароль</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <p className="error">{errors.password.message}</p>}
</div>
<button type="submit">Зареєструватися</button>
</form>
);
}
validate приймає функцію, яка може бути синхронною або асинхронною. Якщо функція повертає true або undefined, валідація пройдена. Якщо повертає рядок — це повідомлення про помилку.Стан isValidating у formState вказує, чи виконується асинхронна валідація у даний момент — це дозволяє показати спінер або повідомлення «Перевірка...».mode: "onChange"). Рекомендується використовувати mode: "onBlur" або додатково реалізувати debounce-логіку, щоб запити не відправлялися занадто часто.Обробка помилок сервера: setError
Після відправки форми сервер може повернути помилки валідації — наприклад, «Email вже зареєстрований» або «Номер телефону має неправильний формат». React Hook Form дозволяє програмно встановлювати помилки через метод setError.
type ServerError = {
field: "name" | "email" | "password";
message: string;
};
function ServerValidationForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
});
const onSubmit = async (data: RegistrationFormData) => {
try {
// Симуляція запиту до API
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const errors: ServerError[] = await response.json();
// Встановлюємо помилки, отримані від сервера
errors.forEach((error) => {
setError(error.field, {
type: "server",
message: error.message,
});
});
return;
}
const result = await response.json();
console.log("Реєстрація успішна:", result);
} catch (error) {
// Встановлюємо загальну помилку форми
setError("root", {
type: "server",
message: "Помилка з'єднання із сервером. Спробуйте пізніше.",
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{errors.root && (
<div className="error-banner">{errors.root.message}</div>
)}
<div>
<label htmlFor="name">Ім'я</label>
<input id="name" {...register("name")} />
{errors.name && <p className="error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Пароль</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <p className="error">{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Відправка..." : "Зареєструватися"}
</button>
</form>
);
}
"root" використовується для загальних помилок форми, які не прив'язані до конкретного поля (помилки мережі, помилки сервера, тощо). Ви можете створювати кілька кореневих помилок із різними ключами:setError("root.serverError", { message: "Сервер недоступний" });
setError("root.timeout", { message: "Перевищено час очікування" });
errors.root?.serverError, errors.root?.timeout.Інтеграція зі стилізованими компонентами та UI-бібліотеками
У реальних проєктах форми часто будуються на основі готових компонентів з UI-бібліотек (Material-UI, Chakra UI, shadcn/ui). Ці компоненти мають власні API для передачі значень та обробників, що відрізняється від нативних <input>.
Для таких випадків React Hook Form надає компонент <Controller>, який служить адаптером між некерованим підходом RHF та керованими компонентами UI-бібліотек.
Приклад з керованим компонентом
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Припустимо, це стороння UI-бібліотека з власним API
function CustomInput({
value,
onChange,
error,
}: {
value: string;
onChange: (value: string) => void;
error?: string;
}) {
return (
<div className="custom-input">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className={error ? "error" : ""}
/>
{error && <span className="error-message">{error}</span>}
</div>
);
}
const schema = z.object({
username: z.string().min(3, "Мінімум 3 символи"),
});
type FormData = z.infer<typeof schema>;
function ControlledComponentForm() {
const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
username: "",
},
});
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="username"
control={control}
render={({ field }) => (
<CustomInput
value={field.value}
onChange={field.onChange}
error={errors.username?.message}
/>
)}
/>
<button type="submit">Відправити</button>
</form>
);
}
<Controller> приймає три обов'язкові параметри:name: ім'я поля у формі.control: об'єктcontrolзuseForm.render: функція рендерингу, яка отримує об'єктfieldз методамиonChange,onBlur,value,ref.
field.onChange викликається з новим значенням поля, field.value містить поточне значення. React Hook Form автоматично відстежує зміни та валідує поле.Розділення логіки форми на компоненти
Коли форма стає великою (20+ полів), рекомендується розділити її на менші компоненти. React Hook Form дозволяє передавати register, control та errors у дочірні компоненти.
// PersonalInfoSection.tsx
interface PersonalInfoSectionProps {
register: UseFormRegister<RegistrationFormData>;
errors: FieldErrors<RegistrationFormData>;
}
function PersonalInfoSection({ register, errors }: PersonalInfoSectionProps) {
return (
<fieldset>
<legend>Особисті дані</legend>
<div>
<label htmlFor="name">Ім'я</label>
<input id="name" {...register("name")} />
{errors.name && <p className="error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
</fieldset>
);
}
// PasswordSection.tsx
interface PasswordSectionProps {
register: UseFormRegister<RegistrationFormData>;
errors: FieldErrors<RegistrationFormData>;
}
function PasswordSection({ register, errors }: PasswordSectionProps) {
return (
<fieldset>
<legend>Безпека</legend>
<div>
<label htmlFor="password">Пароль</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <p className="error">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="confirmPassword">Підтвердження пароля</label>
<input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p className="error">{errors.confirmPassword.message}</p>
)}
</div>
</fieldset>
);
}
// Головний компонент форми
function ModularRegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
});
const onSubmit = (data: RegistrationFormData) => {
console.log("Дані реєстрації:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<PersonalInfoSection register={register} errors={errors} />
<PasswordSection register={register} errors={errors} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Відправка..." : "Зареєструватися"}
</button>
</form>
);
}
methods із useForm у глибоко вкладені компоненти, щоб уникнути prop-drilling:import { FormProvider, useFormContext } from "react-hook-form";
function ParentForm() {
const methods = useForm<RegistrationFormData>({
resolver: zodResolver(registrationSchema),
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<PersonalInfoSection />
<PasswordSection />
<button type="submit">Зареєструватися</button>
</form>
</FormProvider>
);
}
function PersonalInfoSection() {
const { register, formState: { errors } } = useFormContext<RegistrationFormData>();
return (
<fieldset>
{/* Поля */}
</fieldset>
);
}
FormProvider передає всі методи форми через контекст, а useFormContext витягує їх у дочірньому компоненті.Порівняння з альтернативами: Formik та Yup
До появи React Hook Form у екосистемі React домінувала бібліотека Formik. Вона використовує керований підхід (controlled components), що призводить до більшої кількості ререндерів.
| Критерій | React Hook Form | Formik |
|---|---|---|
| Підхід | Некеровані інпути (uncontrolled) | Керовані інпути (controlled) |
| Продуктивність | Мінімум ререндерів | Ререндер при кожній зміні поля |
| Розмір бандлу | ~9 KB (gzip) | ~13 KB (gzip) |
| API | Хуки та реф-підхід | Компоненти та render props |
| Валідація | Resolver pattern (Zod, Yup, Joi) | Вбудована або Yup |
| TypeScript | Відмінна інтеграція | Добра інтеграція |
import * as yup from "yup";
const schema = yup.object({
name: yup.string().min(2).required(),
email: yup.string().email().required(),
age: yup.number().integer().min(16).max(120).required(),
});
@hookform/resolvers/yup та @hookform/resolvers/zod. Zod рекомендується для нових проєктів завдяки кращій типізації.Практичні завдання
Створіть форму редагування профілю користувача з полями:
- Ім'я (мінімум 2 символи)
- Прізвище (мінімум 2 символи)
- Дата народження (користувач має бути старше 13 років)
- Біографія (необов'язкове поле, максимум 500 символів)
Виведіть помилки під кожним полем. При успішному сабміті покажіть alert з даними.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const profileSchema = z.object({
firstName: z.string().min(2, "Ім'я має містити щонайменше 2 символи"),
lastName: z.string().min(2, "Прізвище має містити щонайменше 2 символи"),
birthDate: z.coerce
.date()
.refine(
(date) => {
const age = (Date.now() - date.getTime()) / (1000 * 60 * 60 * 24 * 365.25);
return age >= 13;
},
{ message: "Вам має бути щонайменше 13 років" }
),
bio: z.string().max(500, "Біографія не може перевищувати 500 символів").optional(),
});
type ProfileFormData = z.infer<typeof profileSchema>;
function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
});
const onSubmit = (data: ProfileFormData) => {
alert(JSON.stringify(data, null, 2));
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="firstName">Ім'я</label>
<input id="firstName" {...register("firstName")} />
{errors.firstName && <p className="error">{errors.firstName.message}</p>}
</div>
<div>
<label htmlFor="lastName">Прізвище</label>
<input id="lastName" {...register("lastName")} />
{errors.lastName && <p className="error">{errors.lastName.message}</p>}
</div>
<div>
<label htmlFor="birthDate">Дата народження</label>
<input
id="birthDate"
type="date"
{...register("birthDate", { valueAsDate: true })}
/>
{errors.birthDate && <p className="error">{errors.birthDate.message}</p>}
</div>
<div>
<label htmlFor="bio">Біографія (необов'язково)</label>
<textarea id="bio" rows={4} {...register("bio")} />
{errors.bio && <p className="error">{errors.bio.message}</p>}
</div>
<button type="submit">Зберегти профіль</button>
</form>
);
}
Пояснення:
z.coerce.date()автоматично перетворює рядкове значення зtype="date"на об'єктDate..refine()додає кастомну валідацію для перевірки віку: обчислюємо різницю між поточною датою та датою народження у роках.valueAsDate: trueуregisterгарантує, що React Hook Form передає самеDate, а не рядок.
Створіть форму оформлення замовлення:
- Ім'я клієнта (обов'язкове)
- Email (обов'язковий, валідація формату)
- Список товарів (динамічний масив):
- Назва товару (обов'язкове)
- Кількість (число, мінімум 1)
- Ціна за одиницю (число, більше 0)
Додайте кнопки «Додати товар» та «Видалити» для кожного товару. Під формою виведіть загальну суму замовлення.
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useMemo } from "react";
const orderSchema = z.object({
customerName: z.string().min(2, "Введіть ім'я"),
email: z.string().email("Введіть коректний email"),
items: z
.array(
z.object({
name: z.string().min(1, "Введіть назву товару"),
quantity: z.coerce.number().int().min(1, "Мінімум 1"),
price: z.coerce.number().positive("Ціна має бути більше 0"),
})
)
.min(1, "Додайте хоча б один товар"),
});
type OrderFormData = z.infer<typeof orderSchema>;
function OrderForm() {
const {
register,
control,
handleSubmit,
watch,
formState: { errors },
} = useForm<OrderFormData>({
resolver: zodResolver(orderSchema),
defaultValues: {
customerName: "",
email: "",
items: [{ name: "", quantity: 1, price: 0 }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
const items = watch("items");
const totalPrice = useMemo(() => {
return items.reduce((sum, item) => {
const quantity = Number(item.quantity) || 0;
const price = Number(item.price) || 0;
return sum + quantity * price;
}, 0);
}, [items]);
const onSubmit = (data: OrderFormData) => {
console.log("Замовлення:", data);
alert(`Замовлення оформлено! Загальна сума: ${totalPrice.toFixed(2)} ₴`);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="customerName">Ім'я клієнта</label>
<input id="customerName" {...register("customerName")} />
{errors.customerName && (
<p className="error">{errors.customerName.message}</p>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<fieldset>
<legend>Товари</legend>
{fields.map((field, index) => (
<div key={field.id} style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
<div>
<label htmlFor={`items.${index}.name`}>Назва товару</label>
<input
id={`items.${index}.name`}
{...register(`items.${index}.name` as const)}
/>
{errors.items?.[index]?.name && (
<p className="error">{errors.items[index]?.name?.message}</p>
)}
</div>
<div>
<label htmlFor={`items.${index}.quantity`}>Кількість</label>
<input
id={`items.${index}.quantity`}
type="number"
{...register(`items.${index}.quantity` as const, { valueAsNumber: true })}
/>
{errors.items?.[index]?.quantity && (
<p className="error">{errors.items[index]?.quantity?.message}</p>
)}
</div>
<div>
<label htmlFor={`items.${index}.price`}>Ціна (₴)</label>
<input
id={`items.${index}.price`}
type="number"
step="0.01"
{...register(`items.${index}.price` as const, { valueAsNumber: true })}
/>
{errors.items?.[index]?.price && (
<p className="error">{errors.items[index]?.price?.message}</p>
)}
</div>
<button type="button" onClick={() => remove(index)} disabled={fields.length === 1}>
Видалити
</button>
</div>
))}
<button type="button" onClick={() => append({ name: "", quantity: 1, price: 0 })}>
+ Додати товар
</button>
{errors.items && typeof errors.items.message === "string" && (
<p className="error">{errors.items.message}</p>
)}
</fieldset>
<div style={{ marginTop: "1rem", fontSize: "1.25rem", fontWeight: "bold" }}>
Загальна сума: {totalPrice.toFixed(2)} ₴
</div>
<button type="submit">Оформити замовлення</button>
</form>
);
}
Пояснення:
useFieldArrayкерує динамічним масивом товарів.watch("items")спостерігає за всіма елементами масиву для обчислення загальної суми.useMemoоптимізує обчислення суми, щоб воно не виконувалося при кожному рендері.disabled={fields.length === 1}блокує видалення, якщо залишився лише один товар (відповідно до валідації.min(1)).
Створіть форму реєстрації на подію:
- Ім'я (обов'язкове)
- Email (обов'язковий)
- Тип квитка (radio: "free", "paid")
- Номер картки (обов'язковий лише якщо обрано "paid", має бути у форматі 16 цифр)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const eventSchema = z
.object({
name: z.string().min(2, "Введіть ім'я"),
email: z.string().email("Введіть коректний email"),
ticketType: z.enum(["free", "paid"]),
cardNumber: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.ticketType === "paid") {
if (!data.cardNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Номер картки обов'язковий для платного квитка",
path: ["cardNumber"],
});
} else if (!/^\d{16}$/.test(data.cardNumber)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Номер картки має містити 16 цифр",
path: ["cardNumber"],
});
}
}
});
type EventFormData = z.infer<typeof eventSchema>;
function EventRegistrationForm() {
const {
register,
watch,
handleSubmit,
formState: { errors },
} = useForm<EventFormData>({
resolver: zodResolver(eventSchema),
defaultValues: {
name: "",
email: "",
ticketType: "free",
cardNumber: "",
},
});
const ticketType = watch("ticketType");
const onSubmit = (data: EventFormData) => {
console.log("Реєстрація:", data);
alert("Ви успішно зареєстровані на подію!");
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">Ім'я</label>
<input id="name" {...register("name")} />
{errors.name && <p className="error">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<fieldset>
<legend>Тип квитка</legend>
<label>
<input type="radio" value="free" {...register("ticketType")} />
Безкоштовний
</label>
<label>
<input type="radio" value="paid" {...register("ticketType")} />
Платний (100 ₴)
</label>
</fieldset>
{ticketType === "paid" && (
<div>
<label htmlFor="cardNumber">Номер картки</label>
<input
id="cardNumber"
type="text"
placeholder="1234567890123456"
maxLength={16}
{...register("cardNumber")}
/>
{errors.cardNumber && (
<p className="error">{errors.cardNumber.message}</p>
)}
</div>
)}
<button type="submit">Зареєструватися</button>
</form>
);
}
Пояснення:
.superRefine()дозволяє додавати складну умовну логіку: перевіряємо тип квитка, і якщо він "paid", валідуємо номер картки.watch("ticketType")використовується для умовного рендерингу поля з номером картки.- Регулярний вираз
/^\d{16}$/перевіряє, що номер картки складається рівно з 16 цифр.
Підсумок
✅ Що ми опанували
- Філософію React Hook Form: некеровані інпути та мінімізація ререндерів.
- Створення типобезпечних схем валідації через Zod із автоматичним виведенням TypeScript-типів.
- Роботу з різними типами полів: текст, числа, дати, чекбокси, радіо-кнопки, селекти.
- Міжполеву валідацію через
.refine()та.superRefine(). - Динамічні поля та масиви об'єктів через
useFieldArray. - Асинхронну валідацію та обробку помилок сервера.
- Інтеграцію з UI-бібліотеками через
<Controller>. - Модульну структуру складних форм через
FormProviderтаuseFormContext.
🎯 Ключові переваги підходу RHF + Zod
- Типобезпека: єдина схема для валідації та TypeScript-типів — жодного дублювання.
- Продуктивність: мінімум ререндерів завдяки некерованому підходу.
- Декларативність: вся логіка валідації зібрана в одному місці (Zod-схема).
- Зручність: інтуїтивний API через хуки та spread-оператор.
- Гнучкість: підтримка складних сценаріїв (динамічні поля, асинхронна валідація, умовна логіка).
- Інтеграція: легко працює з будь-якими UI-бібліотеками через
<Controller>.
- Використовуйте Zod замість Yup для нових проєктів — краща інтеграція з TypeScript та функціональний підхід.
- Винесіть схеми валідації у окремі файли (
schemas/registration.schema.ts) для повторного використання на фронтенді та бекенді. - Створюйте бібліотеку переважних UI-компонентів форм (Input, Select, Checkbox) інтегрованих із React Hook Form для уніфікації коду.
- Використовуйте
FormProviderдля великих форм із глибокою ієрархією компонентів. - Застосовуйте debounce для асинхронної валідації, щоб уникнути надмірних запитів до API.
- Логуйте події форми у середовищі розробки для налагодження:
devTool: { control }(потрібен пакет@hookform/devtools).
RTK Query: запити до сервера без болю
Покрокове введення в RTK Query: від першого запиту до кешування, мутацій, інвалідації тегів, оптимістичних оновлень, пагінації, автентифікації та типізації
Тестування компонентів діаграм та пісочниць
Тестування AsciiDiagram, BoxDiagram, ReactNativePreview та ReactProjectPreview компонентів