Помилки валідації форм
Принципи UX валідації
- Де показувати:
- Польові (inline) під інпутом для конкретної помилки.
- Формові (general) нагорі форми для загальних/серверних помилок.
- Коли показувати:
- При
blur(первинний фідбек) і приsubmit; - По
change- тільки якщо поле вжеtouched/dirty. - Фокус і доступність (a11y):
- Переносьте фокус на перший помилковий інпут після submit.
aria-invalid="true",aria-describedby="error-id", контейнер помилок зrole="alert"абоaria-live="polite".- Типи помилок: клієнтські (схема), серверні (наприклад, "email вже зайнятий"). Серверні мапте до полів, інакше - загальний банер.
- Стейт: зберігайте
values,touched,errorsокремо. - Тексти: коротко, зрозумілою людською мовою, без "валідувати значенням pattern 1".
- Не блокуйте ввід: дизейбл кнопки відправлення тільки під час завантаження; при помилках - дозволяйте повтор.
Варіант A: Легка кастомна валідація (з Zod)
javascript
import { useEffect, useMemo, useRef, useState } from "react";
import { z } from "zod";
const schema = z.object({
email: z.string().email("Введіть коректний email"),
password: z.string().min(8, "Мінімум 8 символів"),
});
type Values = z.infer<typeof schema>;
type Errors = Partial<Record<keyof Values, string>>;
export function LoginForm() {
const [values, setValues] = useState<Values>({ email: "", password: "" });
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [errors, setErrors] = useState<Errors>({});
const [formError, setFormError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const firstErrorRef = useRef<HTMLInputElement | null>(null);
const validate = (v: Values): Errors => {
const res = schema.safeParse(v);
if (res.success) return {};
const e: Errors = {};
res.error.issues.forEach(i => {
const path = i.path[0] as keyof Values;
e[path] = i.message;
});
return e;
};
// валідація при зміні вже торкнутих полів
useEffect(() => {
const e = validate(values);
setErrors(e);
}, [values]);
const onChange = (name: keyof Values) => (e: React.ChangeEvent<HTMLInputElement>) => {
setValues(v => ({ ...v, [name]: e.target.value }));
};
const onBlur = (name: keyof Values) => () => setTouched(t => ({ ...t, [name]: true }));
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setTouched({ email: true, password: true });
const eMap = validate(values);
setErrors(eMap);
setFormError(null);
if (Object.keys(eMap).length) {
// фокус на першому помилковому
const firstField = document.querySelector<HTMLInputElement>('[aria-invalid="true"]');
firstField?.focus();
return;
}
try {
setLoading(true);
// імітація запиту
const ok = await fakeLogin(values);
if (!ok) {
// серверна помилка до поля або загальна
setErrors(prev => ({ ...prev, email: "Email вже зареєстрований" }));
}
} catch (err) {
setFormError("Не вдалося надіслати форму. Спробуйте пізніше.");
} finally {
setLoading(false);
}
};
const emailInvalid = !!errors.email && touched.email;
const passInvalid = !!errors.password && touched.password;
return (
<form onSubmit={onSubmit} noValidate>
{formError && (
<div role="alert" className="mb-3 text-red-600">
{formError}
</div>
)}
<label>
Email
<input
type="email"
value={values.email}
onChange={onChange("email")}
onBlur={onBlur("email")}
aria-invalid={emailInvalid || undefined}
aria-describedby={emailInvalid ? "email-error" : undefined}
/>
</label>
{emailInvalid && (
<div id="email-error" role="alert" className="text-red-600 text-sm">
{errors.email}
</div>
)}
<label>
Пароль
<input
type="password"
value={values.password}
onChange={onChange("password")}
onBlur={onBlur("password")}
aria-invalid={passInvalid || undefined}
aria-describedby={passInvalid ? "password-error" : undefined}
/>
</label>
{passInvalid && (
<div id="password-error" role="alert" className="text-red-600 text-sm">
{errors.password}
</div>
)}
<button type="submit" disabled={loading}>
{loading ? "Надсилання..." : "Увійти"}
</button>
</form>
);
}
async function fakeLogin(_: { email: string; password: string }) {
await new Promise(r => setTimeout(r, 600));
return false; // повернемо "email зайнятий"
}Що тут добре: touched-логіка, inline-помилки, загальний банер, фокус на першому помилковому полі, мапінг серверних помилок.
Варіант B: React Hook Form + Zod (мінімум коду, максимум фіч)
javascript
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
email: z.string().email("Введіть коректний email"),
password: z.string().min(8, "Мінімум 8 символів"),
});
type FormData = z.infer<typeof schema>;
export function RHFLoginForm() {
const { register, handleSubmit, formState, setError } = useForm<FormData>({
resolver: zodResolver(schema),
mode: "onBlur", // blur + onSubmit
});
const { errors, isSubmitting } = formState;
const onSubmit = async (data: FormData) => {
try {
const ok = await fakeLogin(data);
if (!ok) {
// серверна помилка до конкретного поля
setError("email", { type: "server", message: "Email вже зареєстрований" });
}
} catch {
// загальна помилка форми
setError("root", { type: "server", message: "Сервіс недоступний. Спробуйте пізніше." });
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{errors.root && (
<div role="alert" className="mb-3 text-red-600">
{errors.root.message}
</div>
)}
<label>
Email
<input
type="email"
{...register("email")}
aria-invalid={!!errors.email || undefined}
aria-describedby={errors.email ? "email-error" : undefined}
/>
</label>
{errors.email && (
<div id="email-error" role="alert" className="text-red-600 text-sm">
{errors.email.message}
</div>
)}
<label>
Пароль
<input
type="password"
{...register("password")}
aria-invalid={!!errors.password || undefined}
aria-describedby={errors.password ? "password-error" : undefined}
/>
</label>
{errors.password && (
<div id="password-error" role="alert" className="text-red-600 text-sm">
{errors.password.message}
</div>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Надсилання..." : "Увійти"}
</button>
</form>
);
}Чому це зручно: RHF дає touched/dirty, керування фокусом, setError для серверних відповідей, легку інтеграцію з Zod/Yup, і не ганяє зайві рендери.
Поради з практики
- Сервер -> поле: сервер повернув
{ field:"email", message:"taken" }-> викликайтеsetError("email", { message }). - Сервер -> загальний банер: якщо немає field, кладіть в
errors.root. - Багато помилок: зробіть "summary" згори (список посилань-якорів на поля).
- i18n: зберігайте коди помилок, тексти - в словнику.
- Довгі форми: показуйте помилки секційно; автоскрол до першої помилки.
- Статуси:
isSubmitting,isValidating, лоадер на кнопці; не дизейбліть поля при помилках.
Коротка відповідь
Для співбесідиPremium
Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.