Пошук уроків, статей та іншого контенту
Створіть повну форму реєстрації з React Hook Form, Zod, підтвердженням пароля та обробкою відповіді API.
У цьому уроці створимо повну форму реєстрації на React із такими можливостями:
валідація полів за допомогою Zod;
керування станом форми через React Hook Form;
перевірка збігу пароля та його підтвердження;
відображення помилок біля відповідних полів;
блокування повторного надсилання під час запиту;
обробка успішної відповіді API;
обробка помилок API, зокрема помилки для вже зареєстрованої електронної адреси;
відображення загальної помилки сервера.
Для роботи прикладу потрібні React Hook Form, Zod і адаптер @hookform/resolvers:
npm install react-hook-form zod @hookform/resolversФорма міститиме чотири поля:
ім’я;
електронну адресу;
пароль;
підтвердження пароля.
Схема Zod описує правила валідації незалежно від JSX. Це дозволяє мати одне джерело правди для структури та обмежень даних.
import { z } from "zod";
export const registrationSchema = z
.object({
name: z
.string()
.trim()
.min(2, "Ім’я має містити щонайменше 2 символи")
.max(50, "Ім’я не може містити більше 50 символів"),
email: z
.string()
.trim()
.email("Введіть коректну електронну адресу"),
password: z
.string()
.min(8, "Пароль має містити щонайменше 8 символів")
.regex(/[A-Z]/, "Пароль має містити хоча б одну велику літеру")
.regex(/[a-z]/, "Пароль має містити хоча б одну малу літеру")
.regex(/[0-9]/, "Пароль має містити хоча б одну цифру"),
confirmPassword: z.string(),
})
.superRefine((data, context) => {
if (data.password !== data.confirmPassword) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Паролі не збігаються",
});
}
});
export type RegistrationValues = z.infer<typeof registrationSchema>;superRefineПеревірка довжини або формату одного поля виконується безпосередньо на цьому полі. Але збіг двох полів — це вже перевірка всього об’єкта форми.
Тому для password і confirmPassword використовується superRefine. Параметр path: ["confirmPassword"] вказує, біля якого саме поля потрібно показати помилку.
Компонент нижче очікує такий API-контракт:
Запит:
POST /api/register
Content-Type: application/jsonТіло запиту:
{
"name": "Анна Коваль",
"email": "anna@example.com",
"password": "SecurePass1"
}Успішна відповідь має статус 201:
{
"message": "Реєстрацію успішно завершено"
}Підтвердження пароля не потрібно надсилати на сервер. Воно використовується лише для клієнтської перевірки.
Наприклад, якщо електронна адреса вже використовується:
{
"fieldErrors": {
"email": "Користувач із такою електронною адресою вже існує"
}
}{
"message": "Не вдалося створити обліковий запис"
}import { FormEvent, useState } from "react";
import {
SubmitHandler,
useForm,
} from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const registrationSchema = z
.object({
name: z
.string()
.trim()
.min(2, "Ім’я має містити щонайменше 2 символи")
.max(50, "Ім’я не може містити більше 50 символів"),
email: z
.string()
.trim()
.email("Введіть коректну електронну адресу"),
password: z
.string()
.min(8, "Пароль має містити щонайменше 8 символів")
.regex(/[A-Z]/, "Пароль має містити хоча б одну велику літеру")
.regex(/[a-z]/, "Пароль має містити хоча б одну малу літеру")
.regex(/[0-9]/, "Пароль має містити хоча б одну цифру"),
confirmPassword: z.string(),
})
.superRefine((data, context) => {
if (data.password !== data.confirmPassword) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["confirmPassword"],
message: "Паролі не збігаються",
});
}
});
type RegistrationValues = z.infer<typeof registrationSchema>;
type ParsedApiError = {
message?: string;
fieldErrors?: Record<string, string>;
};
const fieldNames = [
"name",
"email",
"password",
"confirmPassword",
] as const;
type RegistrationField = (typeof fieldNames)[number];
function isRegistrationField(
value: string,
): value is RegistrationField {
return fieldNames.includes(value as RegistrationField);
}
function parseApiError(payload: unknown): ParsedApiError {
if (!payload || typeof payload !== "object") {
return {};
}
const data = payload as Record<string, unknown>;
const message =
typeof data.message === "string"
? data.message
: undefined;
const fieldErrors: Record<string, string> = {};
if (
data.fieldErrors &&
typeof data.fieldErrors === "object"
) {
for (const [field, error] of Object.entries(
data.fieldErrors,
)) {
if (typeof error === "string") {
fieldErrors[field] = error;
}
}
}
return {
message,
fieldErrors,
};
}
export function RegistrationForm() {
const [successMessage, setSuccessMessage] = useState<
string | null
>(null);
const {
register,
handleSubmit,
setError,
reset,
formState: { errors, isSubmitting },
} = useForm<RegistrationValues>({
resolver: zodResolver(registrationSchema),
mode: "onBlur",
defaultValues: {
name: "",
email: "",
password: "",
confirmPassword: "",
},
});
const onSubmit: SubmitHandler<RegistrationValues> = async (
values,
) => {
setSuccessMessage(null);
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: values.name,
email: values.email,
password: values.password,
}),
}).catch(() => null);
if (!response) {
setError("root.server", {
type: "network",
message:
"Не вдалося підключитися до сервера. Спробуйте ще раз.",
});
return;
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
const apiError = parseApiError(payload);
if (apiError.fieldErrors) {
for (const [field, message] of Object.entries(
apiError.fieldErrors,
)) {
if (isRegistrationField(field)) {
setError(field, {
type: "server",
message,
});
}
}
}
if (apiError.message) {
setError("root.server", {
type: "server",
message: apiError.message,
});
} else if (
!apiError.fieldErrors ||
Object.keys(apiError.fieldErrors).length === 0
) {
setError("root.server", {
type: "server",
message:
"Не вдалося завершити реєстрацію. Спробуйте ще раз.",
});
}
return;
}
const message =
payload &&
typeof payload === "object" &&
"message" in payload &&
typeof payload.message === "string"
? payload.message
: "Реєстрацію успішно завершено";
reset();
setSuccessMessage(message);
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
aria-busy={isSubmitting}
>
<div>
<label htmlFor="name">Ім’я</label>
<input
id="name"
type="text"
autoComplete="name"
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" role="alert">
{errors.name.message}
</p>
)}
</div>
<div>
<label htmlFor="email">Електронна адреса</label>
<input
id="email"
type="email"
autoComplete="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={
errors.email ? "email-error" : undefined
}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="password">Пароль</label>
<input
id="password"
type="password"
autoComplete="new-password"
aria-invalid={Boolean(errors.password)}
aria-describedby={
errors.password ? "password-error" : undefined
}
{...register("password")}
/>
{errors.password && (
<p id="password-error" role="alert">
{errors.password.message}
</p>
)}
</div>
<div>
<label htmlFor="confirmPassword">
Підтвердження пароля
</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
aria-invalid={Boolean(errors.confirmPassword)}
aria-describedby={
errors.confirmPassword
? "confirm-password-error"
: undefined
}
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p id="confirm-password-error" role="alert">
{errors.confirmPassword.message}
</p>
)}
</div>
{errors.root?.server && (
<p role="alert">
{errors.root.server.message}
</p>
)}
{successMessage && (
<p role="status">
{successMessage}
</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Реєстрація..." : "Зареєструватися"}
</button>
</form>
);
}z.inferТип RegistrationValues створюється безпосередньо зі схеми:
type RegistrationValues = z.infer<typeof registrationSchema>;Якщо до схеми додати або видалити поле, TypeScript автоматично оновить тип форми. Це зменшує ризик розбіжностей між Zod-схемою, React Hook Form і кодом запиту.
const {
register,
handleSubmit,
setError,
reset,
formState: { errors, isSubmitting },
} = useForm<RegistrationValues>({
resolver: zodResolver(registrationSchema),
mode: "onBlur",
});zodResolver передає значення форми до Zod і перетворює помилки схеми у формат React Hook Form.
Параметр mode: "onBlur" означає, що поле перевіряється після втрати фокуса. Інші поширені варіанти:
onSubmit — перевірка лише під час надсилання;
onChange — перевірка після кожної зміни;
all — перевірка і під час зміни, і під час втрати фокуса.
Для складних форм onBlur часто є компромісом між швидким зворотним зв’язком і відсутністю надмірної кількості повідомлень під час введення.
handleSubmit<form onSubmit={handleSubmit(onSubmit)}>handleSubmit спочатку запускає клієнтську валідацію. Якщо форма не проходить перевірку, onSubmit не викликається, а помилки доступні в formState.errors.
Отже, API не отримує явно некоректні дані, наприклад пароль, що містить менше восьми символів.
Значення форми містять і confirmPassword, але до API передаються лише:
body: JSON.stringify({
name: values.name,
email: values.email,
password: values.password,
}),Підтвердження пароля не є окремими обліковими даними. Його призначення — переконатися на клієнті, що користувач не припустився помилки під час введення пароля.
Клієнтська валідація не замінює серверну. Навіть якщо дані пройшли Zod, сервер може відхилити запит через:
уже зареєстровану електронну адресу;
тимчасову недоступність сервісу;
додаткові серверні правила;
помилку мережі.
Для помилки, що стосується конкретного поля, використовується setError:
setError("email", {
type: "server",
message: "Користувач із такою електронною адресою вже існує",
});Після цього помилка поводиться так само, як помилка Zod: вона з’являється в errors.email і відображається біля поля.
Для помилки, яка не належить конкретному полю, React Hook Form підтримує кореневі помилки:
setError("root.server", {
type: "server",
message: "Не вдалося завершити реєстрацію",
});Така помилка зчитується через:
errors.root?.server?.messageЦе зручно для мережевих помилок і повідомлень, що стосуються всієї операції.
response.okfetch не вважає HTTP-відповідь зі статусом 400, 409 або 500 винятком. Тому потрібно перевіряти response.ok вручну:
if (!response.ok) {
// Обробка помилки API
return;
}Виняток виникає переважно тоді, коли запит не вдалося виконати взагалі, наприклад через проблеми з мережею. Саме тому в прикладі результат fetch додатково обробляється через .catch.
React Hook Form надає isSubmitting, який автоматично має значення true, поки асинхронний обробник onSubmit не завершиться.
У прикладі цей стан використовується для:
блокування кнопки;
зміни її тексту;
встановлення атрибута aria-busy на формі.
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Реєстрація..." : "Зареєструватися"}
</button>Блокування кнопки запобігає повторному надсиланню форми під час виконання першого запиту.
Форма має бути зрозумілою не лише візуально.
У прикладі:
кожен label пов’язаний з полем через htmlFor та id;
aria-invalid повідомляє про некоректне значення;
aria-describedby пов’язує поле з текстом помилки;
повідомлення про помилки мають role="alert";
повідомлення про успішну операцію має role="status".
Наприклад:
<input
id="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}Атрибут noValidate вимикає вбудовану браузерну валідацію. У цьому прикладі всі правила контролюються Zod, тому користувач отримує однакову логіку перевірки незалежно від браузера.
confirmPassword на серверПідтвердження пароля потрібно для порівняння на клієнті, але воно не повинно ставати частиною даних, які зберігаються або обробляються як пароль.
Надсилайте лише необхідні поля:
{
name: values.name,
email: values.email,
password: values.password,
}Клієнтський код можна змінити або обійти. Сервер повинен повторно перевіряти:
формат електронної адреси;
вимоги до пароля;
унікальність користувача;
допустимість інших даних.
Zod у React покращує взаємодію з користувачем, але не є захистом API.
response.okКод нижче не обробляє HTTP-помилки належним чином:
const response = await fetch("/api/register");
const data = await response.json();
setSuccessMessage(data.message);Потрібно спочатку перевірити статус:
const response = await fetch("/api/register");
const data = await response.json();
if (!response.ok) {
// Показати помилку API
return;
}
setSuccessMessage(data.message);Якщо сервер повідомляє, що електронна адреса вже існує, краще показати цю помилку біля поля email. Загальне повідомлення без прив’язки до поля змушує користувача самостійно шукати причину.
Не кожна помилка сервера обов’язково містить коректний JSON. Виклик:
const payload = await response.json();може завершитися помилкою. У прикладі використовується:
const payload = await response.json().catch(() => null);Тому форма може показати загальну помилку навіть тоді, коли тіло відповіді порожнє або має некоректний формат.
Якщо після успішної реєстрації користувач знову почне взаємодіяти з формою, старе повідомлення не повинно залишатися безумовно актуальним. Перед новою спробою надсилання воно очищується:
setSuccessMessage(null);Перевірте такі сценарії:
Надішліть порожню форму.
Для обов’язкових полів мають з’явитися повідомлення про помилки.
Введіть некоректну електронну адресу.
Помилка повинна з’явитися біля поля email.
Введіть короткий або недостатньо складний пароль.
Zod має показати відповідне повідомлення.
Введіть різні значення у поля пароля.
Помилка повинна бути прив’язана до confirmPassword.
Надішліть коректні дані.
Кнопка має тимчасово заблокуватися.
Після успішної відповіді форма має очиститися.
З’явиться повідомлення про успіх.
Змоделюйте відповідь із помилкою поля.
Повідомлення API має відобразитися біля відповідного поля.
Змоделюйте мережеву помилку.
Має з’явитися загальна помилка сервера.
React Hook Form керує значеннями, станом і надсиланням форми.
zodResolver підключає схему Zod до React Hook Form.
superRefine дає змогу перевіряти взаємозалежність полів.
z.infer створює TypeScript-тип форми зі схеми.
setError використовується для помилок, які повертає API.
root.server підходить для загальних і мережевих помилок.
isSubmitting допомагає блокувати повторне надсилання.
Підтвердження пароля потрібно перевіряти, але не надсилати до API.
Серверна валідація все одно необхідна, навіть якщо форма перевіряється за допомогою Zod.