React

React Hook Form: Професійна Робота з Формами

React Hook Form: Професійна Робота з Формами

Передумови: Для ефективного засвоєння цього матеріалу вам необхідно знати основи React, зокрема функціональні компоненти, хук useState та основи роботи з формами.

Вступ: Чому Потрібна Бібліотека?

У попередньому матеріалі ми навчилися створювати форми "вручну". Це чудово для розуміння, але в реальних проєктах виникають проблеми:

  1. Boilerplate: Для кожного поля потрібен стан, handler, валідація, помилки
  2. Продуктивність: Controlled компоненти ре-рендерять усю форму при кожному натисканні клавіші
  3. Складність: Вкладені форми, масиви полів, динамічні валідації — це багато коду
  4. Помилки: Легко забути про edge-кейси (touched, dirty, submitting...)

React Hook Form — це бібліотека, яка вирішує всі ці проблеми елегантно та ефективно.


title: "🚀 Продуктивність"

Мінімальні ре-рендери через використання uncontrolled inputs та ref-ів


title: "📦 Легкість"

~12KB gzipped — одна з найлегших бібліотек для форм


title: "💡 Простота"

Мінімум boilerplate — форма на 10 полів займає 20 рядків


title: "🔧 Гнучкість"

Інтеграція з Yup, Zod, Joi та іншими схемами валідації

Встановлення та Перші Кроки

Встановлення

npm install react-hook-form

Для TypeScript типи вже включені — додаткове встановлення не потрібне.

Базовий Приклад

Порівняймо звичайний підхід та React Hook Form:

import { useState } from 'react'

function LoginForm() {
    const [email, setEmail] = useState('')
    const [password, setPassword] = useState('')
    const [errors, setErrors] = useState({})

    const validate = () => {
        const newErrors = {}
        if (!email) newErrors.email = "Email обов'язковий"
        if (!password) newErrors.password = "Пароль обов'язковий"
        setErrors(newErrors)
        return Object.keys(newErrors).length === 0
    }

    const handleSubmit = (e) => {
        e.preventDefault()
        if (validate()) {
            console.log({ email, password })
        }
    }

    return (
        <form onSubmit={handleSubmit}>
            <input value={email} onChange={(e) => setEmail(e.target.value)} />
            {errors.email && <span>{errors.email}</span>}
            <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
            {errors.password && <span>{errors.password}</span>}
            <button type="submit">Увійти</button>
        </form>
    )
}

Різниця:

  • Немає useState для кожного поля
  • Немає ручних onChange handlers
  • Валідація декларативна — прямо в register
  • Код коротший та читабельніший

Анатомія useForm

Хук useForm — це серце бібліотеки. Розглянемо, що він повертає:

import { useForm } from 'react-hook-form'

function MyForm() {
    const {
        register, // Реєстрація полів
        handleSubmit, // Обробка submit
        formState, // Стан форми (errors, isDirty, isValid...)
        watch, // Спостереження за значеннями
        reset, // Скидання форми
        setValue, // Програмне встановлення значення
        getValues, // Отримання значень
        trigger, // Ручний запуск валідації
        control, // Для інтеграції з Controller
    } = useForm({
        defaultValues: {
            // Початкові значення
            email: '',
            password: '',
        },
        mode: 'onBlur', // Коли валідувати: 'onSubmit' | 'onBlur' | 'onChange' | 'all'
    })

    // ...
}

register — Реєстрація Полів

Функція register повертає об'єкт з пропсами для інпуту:

const { register } = useForm();

// register('fieldName') повертає:
// {
//   name: 'fieldName',
//   ref: [Function],
//   onChange: [Function],
//   onBlur: [Function]
// }

// Тому використовуємо spread:
<input {...register('email')} />

// Еквівалентно:
<input
  name="email"
  ref={...}
  onChange={...}
  onBlur={...}
/>

Правила Валідації

<input
    {...register('username', {
        required: "Поле обов'язкове", // Обов'язкове поле
        minLength: {
            value: 3,
            message: 'Мінімум 3 символи',
        },
        maxLength: {
            value: 20,
            message: 'Максимум 20 символів',
        },
        pattern: {
            value: /^[a-zA-Z0-9]+$/,
            message: 'Тільки латиниця та цифри',
        },
        validate: {
            // Кастомні валідатори
            notAdmin: (value) => value !== 'admin' || "Це ім'я зарезервоване",
            noSpaces: (value) => !value.includes(' ') || 'Пробіли заборонені',
        },
    })}
/>

Доступні правила:

ПравилоОписПриклад
requiredОбов'язкове полеrequired: 'Повідомлення' або required: true
minМінімальне числове значенняmin: { value: 18, message: '18+' }
maxМаксимальне числове значенняmax: 100
minLengthМінімальна довжинаminLength: { value: 3, message: '...' }
maxLengthМаксимальна довжинаmaxLength: 10
patternРегулярний виразpattern: { value: /regex/, message: '...' }
validateКастомні функціїvalidate: (value) => value > 0 || 'Помилка'

formState — Стан Форми

const { formState } = useForm()

const {
    errors, // Об'єкт з помилками
    isDirty, // Чи змінювалися значення
    dirtyFields, // Які саме поля змінені
    touchedFields, // Які поля були "торкнуті"
    isSubmitting, // Чи форма відправляється
    isSubmitted, // Чи була спроба submit
    isValid, // Чи форма валідна
    submitCount, // Кількість спроб submit
} = formState

Практичне використання:

function AdvancedForm() {
    const {
        register,
        handleSubmit,
        formState: { errors, isSubmitting, isDirty, isValid },
    } = useForm({ mode: 'onChange' })

    return (
        <form onSubmit={handleSubmit(onSubmit)}>
            <input {...register('email', { required: true })} />

            <button
                type="submit"
                disabled={!isDirty || !isValid || isSubmitting} // Розумна кнопка!            >
                {isSubmitting ? 'Відправляємо...' : 'Відправити'}
            </button>
        </form>
    )
}

watch — Спостереження за Значеннями

Іноді потрібно реагувати на зміни значень (наприклад, умовні поля):

function ConditionalForm() {
    const { register, watch } = useForm()

    // Спостерігаємо за одним полем    const hasNewsletter = watch('newsletter') 
    // Або за кількома
    const [email, password] = watch(['email', 'password'])

    // Або за всіма
    const allValues = watch()

    return (
        <form>
            <label>
                <input type="checkbox" {...register('newsletter')} />
                Підписатися на розсилку
            </label>

            {hasNewsletter && ( // Умовне поле!                <input {...register('frequency')} placeholder="Як часто?" />
            )}
        </form>
    )
}
Увага: watch викликає ре-рендер при кожній зміні спостережуваного поля. Використовуйте обережно або розгляньте useWatch для оптимізації.

Повний Приклад: Форма Реєстрації

import { useForm } from 'react-hook-form'

function RegistrationForm() {
    const {
        register,
        handleSubmit,
        watch,
        formState: { errors, isSubmitting },
    } = useForm({
        defaultValues: {
            username: '',
            email: '',
            password: '',
            confirmPassword: '',
            age: '',
            terms: false,
        },
        mode: 'onBlur',
    })

    const password = watch('password') // Для порівняння паролів

    const onSubmit = async (data) => {
        // Симуляція API-запиту
        await new Promise((resolve) => setTimeout(resolve, 2000))
        console.log('Дані форми:', data)
        alert('Реєстрація успішна!')
    }

    return (
        <form onSubmit={handleSubmit(onSubmit)} className="registration-form">
            <h2>Реєстрація</h2>

            {/* Username */}
            <div className="field">
                <label>Ім'я користувача</label>
                <input
                    {...register('username', {
                        required: "Ім'я обов'язкове",
                        minLength: { value: 3, message: 'Мінімум 3 символи' },
                        maxLength: { value: 20, message: 'Максимум 20 символів' },
                        pattern: {
                            value: /^[a-zA-Z0-9_]+$/,
                            message: 'Тільки латиниця, цифри та _',
                        },
                    })}
                    className={errors.username ? 'error' : ''}
                />
                {errors.username && <span className="error-text">{errors.username.message}</span>}
            </div>

            {/* Email */}
            <div className="field">
                <label>Email</label>
                <input
                    type="email"
                    {...register('email', {
                        required: "Email обов'язковий",
                        pattern: {
                            value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
                            message: 'Невалідний email',
                        },
                    })}
                    className={errors.email ? 'error' : ''}
                />
                {errors.email && <span className="error-text">{errors.email.message}</span>}
            </div>

            {/* Password */}
            <div className="field">
                <label>Пароль</label>
                <input
                    type="password"
                    {...register('password', {
                        required: "Пароль обов'язковий",
                        minLength: { value: 8, message: 'Мінімум 8 символів' },
                        validate: {
                            hasUppercase: (v) => /[A-Z]/.test(v) || 'Потрібна велика літера',
                            hasLowercase: (v) => /[a-z]/.test(v) || 'Потрібна мала літера',
                            hasNumber: (v) => /[0-9]/.test(v) || 'Потрібна цифра',
                        },
                    })}
                    className={errors.password ? 'error' : ''}
                />
                {errors.password && <span className="error-text">{errors.password.message}</span>}
            </div>

            {/* Confirm Password */}
            <div className="field">
                <label>Підтвердіть пароль</label>
                <input
                    type="password"
                    {...register('confirmPassword', {
                        required: 'Підтвердіть пароль',
                        validate: (value) => value === password || 'Паролі не співпадають',                     })}
                    className={errors.confirmPassword ? 'error' : ''}
                />
                {errors.confirmPassword && <span className="error-text">{errors.confirmPassword.message}</span>}
            </div>

            {/* Age */}
            <div className="field">
                <label>Вік</label>
                <input
                    type="number"
                    {...register('age', {
                        required: 'Вкажіть вік',
                        min: { value: 18, message: 'Вам має бути 18+' },
                        max: { value: 120, message: 'Невалідний вік' },
                    })}
                    className={errors.age ? 'error' : ''}
                />
                {errors.age && <span className="error-text">{errors.age.message}</span>}
            </div>

            {/* Terms */}
            <div className="field checkbox">
                <label>
                    <input
                        type="checkbox"
                        {...register('terms', {
                            required: 'Прийміть умови використання',
                        })}
                    />
                    Я приймаю умови використання
                </label>
                {errors.terms && <span className="error-text">{errors.terms.message}</span>}
            </div>

            <button type="submit" disabled={isSubmitting}>
                {isSubmitting ? 'Реєстрація...' : 'Зареєструватися'}
            </button>
        </form>
    )
}

export default RegistrationForm
Loading diagram...
flowchart TD
    A[Користувач заповнює форму] --> B{onBlur валідація}
    B -->|Помилка| C[Показ помилки]
    B -->|OK| D[Поле валідне]
    C --> A
    D --> E[Submit натиснуто]
    E --> F{handleSubmit валідація}
    F -->|Є помилки| G[Показ всіх помилок]
    F -->|Все OK| H[onSubmit callback]
    H --> I[API запит]
    I --> J[isSubmitting = true]
    J --> K[Успіх / Помилка]
    style A fill:#3b82f6,stroke:#1d4ed8,color:#ffffff
    style F fill:#f59e0b,stroke:#b45309,color:#ffffff
    style H fill:#22c55e,stroke:#15803d,color:#ffffff
    style G fill:#ef4444,stroke:#b91c1c,color:#ffffff

Робота з Controller для Кастомних Компонентів

Що робити, якщо ви використовуєте UI-бібліотеку (Material UI, Ant Design, Chakra)? Їхні компоненти не мають нативного ref. Тут допоможе Controller:

import { useForm, Controller } from 'react-hook-form'
import Select from 'react-select' // Популярна бібліотека для select

function FormWithCustomSelect() {
    const { control, handleSubmit } = useForm()

    const options = [
        { value: 'ua', label: 'Україна' },
        { value: 'pl', label: 'Польща' },
        { value: 'de', label: 'Німеччина' },
    ]

    return (
        <form onSubmit={handleSubmit(console.log)}>
            <Controller
                name="country"
                control={control}
                rules={{ required: 'Виберіть країну' }}
                render={(
                    { field, fieldState: { error } },                 ) => (
                    <>
                        <Select {...field} options={options} placeholder="Виберіть країну..." />
                        {error && <span className="error">{error.message}</span>}
                    </>
                )}
            />
            <button type="submit">Відправити</button>
        </form>
    )
}

Як працює Controller:

  • control — зв'язок з useForm
  • name — ім'я поля
  • rules — правила валідації
  • render — функція, що отримує field (value, onChange, onBlur, ref) та fieldState

Масиви Полів з useFieldArray

Для динамічних списків (наприклад, кілька телефонів чи адрес):

import { useForm, useFieldArray } from 'react-hook-form'

function DynamicForm() {
    const {
        register,
        control,
        handleSubmit,
        formState: { errors },
    } = useForm({
        defaultValues: {
            phones: [{ number: '' }], // Початково один телефон
        },
    })

    const { fields, append, remove } = useFieldArray({
        control,
        name: 'phones',
    })

    return (
        <form onSubmit={handleSubmit(console.log)}>
            <h3>Телефони:</h3>

            {fields.map((field, index) => (
                <div key={field.id}>
                    {' '}
                    {/* Важливо: використовуємо field.id, не index! */}
                    <input
                        {...register(`phones.${index}.number`, {
                            required: "Номер обов'язковий",
                            pattern: {
                                value: /^\+?[\d\s-]+$/,
                                message: 'Невалідний номер',
                            },
                        })}
                        placeholder={`Телефон ${index + 1}`}
                    />
                    {fields.length > 1 && (
                        <button type="button" onClick={() => remove(index)}>
                            Видалити
                        </button>
                    )}
                    {errors.phones?.[index]?.number && (
                        <span className="error">{errors.phones[index].number.message}</span>
                    )}
                </div>
            ))}

            <button type="button" onClick={() => append({ number: '' })}>
                Додати телефон
            </button>

            <button type="submit">Зберегти</button>
        </form>
    )
}

Методи useFieldArray:

МетодОпис
fieldsМасив полів з унікальними id
append(obj)Додати елемент в кінець
prepend(obj)Додати елемент на початок
insert(index, obj)Вставити за індексом
remove(index)Видалити за індексом
swap(indexA, indexB)Поміняти місцями
move(from, to)Перемістити
update(index, obj)Оновити елемент
replace(arr)Замінити весь масив

Інтеграція з Yup/Zod

Для складних форм краще винести схему валідації окремо. React Hook Form підтримує Yup, Zod, Joi та інші через @hookform/resolvers:

npm install @hookform/resolvers yup
# або
npm install @hookform/resolvers zod
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as yup from 'yup'

// Схема валідації окремо!const schema = yup.object({
    username: yup.string().required("Ім'я обов'язкове").min(3, 'Мінімум 3 символи'),
    email: yup.string().required("Email обов'язковий").email('Невалідний email'),
    age: yup.number().required('Вкажіть вік').min(18, '18+').typeError('Має бути число'),
})

function YupForm() {
    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm({
        resolver: yupResolver(schema), // Підключаємо resolver    })

    return (
        <form onSubmit={handleSubmit(console.log)}>
            <input {...register('username')} />
            {errors.username && <span>{errors.username.message}</span>}

            <input {...register('email')} />
            {errors.email && <span>{errors.email.message}</span>}

            <input type="number" {...register('age')} />
            {errors.age && <span>{errors.age.message}</span>}

            <button type="submit">Відправити</button>
        </form>
    )
}

Оптимізація Продуктивності

Проблема Ре-рендерів

При використанні watch весь компонент ре-рендериться при зміні спостережуваного поля. Для великих форм це може бути проблемою.

Рішення 1: useWatch

import { useForm, useWatch } from 'react-hook-form'

// Виносимо спостереження в окремий компонентfunction WatchedField({ control }) {
    const email = useWatch({ control, name: 'email' })     return <p>Ваш email: {email}</p>
}

function OptimizedForm() {
    const { register, control, handleSubmit } = useForm()

    return (
        <form onSubmit={handleSubmit(console.log)}>
            <input {...register('email')} />
            <input {...register('password')} />

            {/* Тільки цей компонент ре-рендериться при зміні email */}
            <WatchedField control={control} />

            <button type="submit">Відправити</button>
        </form>
    )
}

Рішення 2: memo для полів

import { memo } from 'react'
import { useFormContext } from 'react-hook-form'

// Мемоізований компонент поляconst TextField = memo(function TextField({ name, rules, ...props }) {
    const {
        register,
        formState: { errors },
    } = useFormContext()

    return (
        <div>
            <input {...register(name, rules)} {...props} />
            {errors[name] && <span>{errors[name].message}</span>}
        </div>
    )
})

// FormProvider для контексту
import { useForm, FormProvider } from 'react-hook-form'

function OptimizedForm() {
    const methods = useForm()

    return (
        <FormProvider {...methods}>
            <form onSubmit={methods.handleSubmit(console.log)}>
                <TextField name="email" rules={{ required: true }} />
                <TextField name="password" type="password" rules={{ required: true }} />
                <button type="submit">Відправити</button>
            </form>
        </FormProvider>
    )
}

Типізація з TypeScript

React Hook Form чудово працює з TypeScript:

import { useForm, SubmitHandler } from 'react-hook-form'

// Опис типу формиinterface RegistrationFormData {
    username: string
    email: string
    password: string
    age: number
    newsletter: boolean
}

function TypedForm() {
    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm<RegistrationFormData>({
        // Передаємо тип!        defaultValues: {
            username: '',
            email: '',
            password: '',
            age: 18,
            newsletter: false,
        },
    })

    const onSubmit: SubmitHandler<RegistrationFormData> = (data) => {
        // data має тип RegistrationFormData
        console.log(data.username) // TypeScript знає про це поле
        console.log(data.email)
    }

    return (
        <form onSubmit={handleSubmit(onSubmit)}>
            {/* register тепер підказує доступні поля */}
            <input {...register('username', { required: true })} />
            <input {...register('email', { required: true })} />
            <input type="password" {...register('password', { required: true })} />
            <input type="number" {...register('age', { valueAsNumber: true })} />
            <input type="checkbox" {...register('newsletter')} />

            <button type="submit">Зареєструватися</button>
        </form>
    )
}

DevTools для Відлагодження

React Hook Form має офіційний DevTools для дебагу:

npm install -D @hookform/devtools
import { useForm } from 'react-hook-form'
import { DevTool } from '@hookform/devtools'

function FormWithDevTools() {
    const { register, control, handleSubmit } = useForm()

    return (
        <>
            <form onSubmit={handleSubmit(console.log)}>
                <input {...register('test')} />
                <button type="submit">Submit</button>
            </form>

            {/* DevTools показує стан форми в реальному часі */}
            <DevTool control={control} />
        </>
    )
}

DevTools показує:

  • Всі зареєстровані поля
  • Поточні значення
  • Помилки валідації
  • Стан touched/dirty
  • Історію змін

Порівняння з Іншими Бібліотеками

КритерійReact Hook FormFormikReact Final Form
Розмір~12KB~45KB~15KB
ПідхідUncontrolledControlledSubscription
Ре-рендериМінімальніНа кожну змінуМінімальні
TypeScriptВбудованийОкремі типиОкремі типи
ВалідаціяВбудована + Yup/ZodYupВбудована
DevToolsОфіційніCommunityНемає
APIHooksComponent + HooksComponent + Hooks

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

Підсумок

React Hook Form — це потужний інструмент для роботи з формами в React, який:

Зменшує Boilerplate

Замість десятків useState — один useForm з декларативною валідацією.

Оптимізує Продуктивність

Використовує uncontrolled inputs та ref-и для мінімізації ре-рендерів.

Інтегрується з Екосистемою

Працює з популярними UI-бібліотеками через Controller та схемами валідації через resolvers.

Масштабується

Від простих форм до складних багатокрокових wizard-ів з динамічними полями.

Рекомендація: Починайте з простого register та handleSubmit. Додавайте Controller, useFieldArray та resolvers по мірі зростання складності.

Корисні Посилання