Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Помилки валідації форм». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Помилки валідації форм** показують поруч з полем (inline) для конкретної помилки та над формою (general) для загальних чи серверних помилок, при цьому враховуючи `blur`, `submit` і a11y-атрибути (`aria-invalid`, `aria-describedby`, `role="alert"`). **Ключове:** тримай `values`, `touched` і `errors` в окремому стані, переноси фокус на перше помилкове поле після сабміту і мапи серверні помилки до конкретних полів, коли це можливо.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення## Принципи 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`, лоадер на кнопці; не дизейбліть поля при помилках.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.