React Typescript

React Hook Form + Zod: типобезпечна валідація форм

Опанування сучасного підходу до роботи з формами через React Hook Form та декларативної валідації даних за допомогою Zod. Створення типізованих схем, інтеграція із zodResolver, обробка помилок, динамічні поля та архітектурні патерни для складних форм.

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 не знає структуру даних форми автоматично — доводиться вручну підтримувати синхронізацію між інтерфейсами типів і реальною структурою форми.

Філософія React Hook Form:Замість того щоб робити всі поля керованими та викликати ререндер при кожній зміні, React Hook Form використовує некеровані інпути (uncontrolled inputs) та реф-підхід: значення полів зберігаються безпосередньо у DOM, а React Hook Form лише підписується на події через ref та валідує дані при сабміті або за потреби.Це радикально зменшує кількість ререндерів та покращує продуктивність, зберігаючи зручний API.

Встановлення бібліотек

npm install 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;
};
Це означає, що ви описуєте структуру даних один раз у схемі Zod, а TypeScript автоматично отримує типи. Жодного дублювання, жодної ручної синхронізації.

Крок 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>
  );
}
Ключові елементи цього коду:
  1. useForm<RegistrationFormData>: хук приймає дженерик-тип, що забезпечує типобезпеку при роботі з полями.
  2. resolver: zodResolver(registrationSchema): підключає Zod-схему як валідатор форми.
  3. register("name"): повертає об'єкт з ref, name, onChange, onBlur — ці атрибути spread-оператор прив'язує до інпута.
  4. handleSubmit(onSubmit): обробник сабміту, що валідує дані перед викликом onSubmit. Якщо валідація не пройдена, onSubmit не викликається, натомість errors заповнюється помилками.
  5. 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.
Це дозволяє створювати складну UX-логіку: блокувати кнопку до першої зміни, показувати індикатор незбережених змін тощо.

Складні типи даних: числа, дати, чекбокси, радіо-кнопки

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>
  );
}
Zod-енум автоматично генерує точний union-тип у TypeScript. Це означає, що автодоповнення та перевірка типів працюють ідеально — TypeScript знає всі можливі значення поля 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:
  1. control: об'єкт з useForm, що передається у useFieldArray для синхронізації стану.
  2. name: "phones": шлях до масиву у структурі форми.
  3. fields: масив елементів, кожен з яких має унікальний id (використовуйте його як key у React).
  4. append(value): додає новий елемент у масив.
  5. remove(index): видаляє елемент за індексом.
  6. insert(index, value): вставляє елемент у конкретну позицію.
  7. swap(indexA, indexB): міняє місцями два елементи.
  8. move(from, to): переміщує елемент з однієї позиції в іншу.
React Hook Form автоматично відстежує всі зміни у масиві та валідує кожен елемент окремо.
Важливо використовувати 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> приймає три обов'язкові параметри:
  1. name: ім'я поля у формі.
  2. control: об'єкт control з useForm.
  3. 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>
  );
}
Для великих форм можна використовувати React Context для передачі 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 FormFormik
ПідхідНекеровані інпути (uncontrolled)Керовані інпути (controlled)
ПродуктивністьМінімум ререндерівРерендер при кожній зміні поля
Розмір бандлу~9 KB (gzip)~13 KB (gzip)
APIХуки та реф-підхідКомпоненти та render props
ВалідаціяResolver pattern (Zod, Yup, Joi)Вбудована або Yup
TypeScriptВідмінна інтеграціяДобра інтеграція
Yup — це альтернативна бібліотека валідації, схожа на Zod. Основна відмінність: Yup базується на класах та ланцюжках методів, тоді як Zod — на функціональному підході та кращій інтеграції з TypeScript.Приклад Yup-схеми:
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(),
});
React Hook Form підтримує обидві бібліотеки через @hookform/resolvers/yup та @hookform/resolvers/zod. Zod рекомендується для нових проєктів завдяки кращій типізації.

Практичні завдання


Підсумок

✅ Що ми опанували

  • Філософію React Hook Form: некеровані інпути та мінімізація ререндерів.
  • Створення типобезпечних схем валідації через Zod із автоматичним виведенням TypeScript-типів.
  • Роботу з різними типами полів: текст, числа, дати, чекбокси, радіо-кнопки, селекти.
  • Міжполеву валідацію через .refine() та .superRefine().
  • Динамічні поля та масиви об'єктів через useFieldArray.
  • Асинхронну валідацію та обробку помилок сервера.
  • Інтеграцію з UI-бібліотеками через <Controller>.
  • Модульну структуру складних форм через FormProvider та useFormContext.

🎯 Ключові переваги підходу RHF + Zod

  • Типобезпека: єдина схема для валідації та TypeScript-типів — жодного дублювання.
  • Продуктивність: мінімум ререндерів завдяки некерованому підходу.
  • Декларативність: вся логіка валідації зібрана в одному місці (Zod-схема).
  • Зручність: інтуїтивний API через хуки та spread-оператор.
  • Гнучкість: підтримка складних сценаріїв (динамічні поля, асинхронна валідація, умовна логіка).
  • Інтеграція: легко працює з будь-якими UI-бібліотеками через <Controller>.
Рекомендації для реальних проєктів:
  1. Використовуйте Zod замість Yup для нових проєктів — краща інтеграція з TypeScript та функціональний підхід.
  2. Винесіть схеми валідації у окремі файли (schemas/registration.schema.ts) для повторного використання на фронтенді та бекенді.
  3. Створюйте бібліотеку переважних UI-компонентів форм (Input, Select, Checkbox) інтегрованих із React Hook Form для уніфікації коду.
  4. Використовуйте FormProvider для великих форм із глибокою ієрархією компонентів.
  5. Застосовуйте debounce для асинхронної валідації, щоб уникнути надмірних запитів до API.
  6. Логуйте події форми у середовищі розробки для налагодження: devTool: { control } (потрібен пакет @hookform/devtools).
Copyright © 2026