← React/React Hook Form: производительные формы#287 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

React Hook Form: производительные формы

Проблема контролируемых форм

Стандартный React-подход к формам — контролируемые компоненты: каждый ввод в поле вызывает setState → ре-рендер компонента. Для формы с 20 полями это 20 ре-рендеров при каждом нажатии клавиши:

// Контролируемая форма: рендер при КАЖДОМ нажатии клавиши
function SlowForm() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  // ... ещё 18 полей

  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      {/* На каждый keydown: setState → рендер всех 20 полей */}
    </form>
  )
}

React Hook Form использует неконтролируемые компоненты: читает значения из DOM через refs только при необходимости (submit или валидация). Результат — нулевые ре-рендеры при вводе.

useForm: основа

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

function RegistrationForm() {
  const {
    register,      // регистрирует поля
    handleSubmit,  // обёртка для onSubmit
    formState: { errors, isSubmitting, isValid },
    watch,         // отслеживает значения полей
    setValue,      // программная установка значения
    reset,         // сброс формы
  } = useForm({
    defaultValues: {
      name: '',
      email: '',
      age: 18,
    }
  })

  const onSubmit = async (data) => {
    // data: { name, email, age } — только при submit!
    await saveUser(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('name', {
          required: 'Имя обязательно',
          minLength: { value: 2, message: 'Минимум 2 символа' },
        })}
      />
      {errors.name && <p>{errors.name.message}</p>}

      <input
        {...register('email', {
          required: 'Email обязателен',
          pattern: { value: /^[^@]+@[^@]+$/, message: 'Неверный email' },
        })}
      />
      {errors.email && <p>{errors.email.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Сохраняем...' : 'Зарегистрироваться'}
      </button>
    </form>
  )
}

Режимы валидации

useForm({
  mode: 'onSubmit',   // по умолчанию: только при submit
  mode: 'onBlur',     // при потере фокуса
  mode: 'onChange',   // при каждом изменении (как раньше)
  mode: 'onTouched',  // первый раз onBlur, потом onChange
  mode: 'all',        // всегда
})

Zod + zodResolver: типобезопасная валидация

import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'

const schema = z.object({
  name: z.string().min(2, 'Минимум 2 символа').max(50),
  email: z.string().email('Неверный email'),
  age: z.number().min(18, 'Должно быть 18+').max(120),
  password: z.string().min(8, 'Минимум 8 символов'),
  confirmPassword: z.string(),
}).refine(
  data => data.password === data.confirmPassword,
  { message: 'Пароли не совпадают', path: ['confirmPassword'] }
)

function Form() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),  // Zod проводит валидацию
  })
  // TypeScript автоматически знает типы полей из schema!
}

Controller: UI-библиотеки

Некоторые компоненты (Select из Material UI, DatePicker) не работают с нативным ref. Используйте Controller:

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

<Controller
  name="country"
  control={control}
  rules={{ required: 'Выберите страну' }}
  render={({ field }) => (
    <CustomSelect
      {...field}  // value, onChange, onBlur
      options={countries}
    />
  )}
/>

watch и setValue

const { watch, setValue } = useForm()

// Отслеживаем значение поля (вызывает ре-рендер)
const country = watch('country')
const [city, country] = watch(['city', 'country'])

// Программная установка
function fillFromProfile() {
  setValue('name', currentUser.name)
  setValue('email', currentUser.email, { shouldValidate: true })
}

// Подписка без ре-рендера (для side effects):
useEffect(() => {
  const subscription = watch((values) => {
    console.log('Форма изменилась:', values)
  })
  return () => subscription.unsubscribe()
}, [watch])

Вложенные поля и массивы

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

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

return (
  <div>
    {fields.map((field, i) => (
      <div key={field.id}>
        <input {...register('phoneNumbers.' + i + '.number')} />
        <button onClick={() => remove(i)}>Удалить</button>
      </div>
    ))}
    <button onClick={() => append({ number: '' })}>Добавить телефон</button>
  </div>
)

Примеры

Реализация концепции react-hook-form: неконтролируемые поля с ref, подписочная система валидации, сбор данных только при submit

// Строим упрощённый аналог react-hook-form:
// неконтролируемые поля + валидация по событиям + нулевые рендеры.

// --- Движок валидации ---

function createValidator(rules) {
  return function validate(value) {
    for (const [ruleName, ruleConfig] of Object.entries(rules)) {
      const ruleValue = typeof ruleConfig === 'object' ? ruleConfig.value : ruleConfig
      const message = typeof ruleConfig === 'object' ? ruleConfig.message : null

      switch (ruleName) {
        case 'required':
          if (ruleValue && (!value || value.trim() === '')) {
            return message || 'Поле обязательно'
          }
          break
        case 'minLength':
          if (value && value.length < ruleValue) {
            return message || ('Минимум ' + ruleValue + ' символов')
          }
          break
        case 'maxLength':
          if (value && value.length > ruleValue) {
            return message || ('Максимум ' + ruleValue + ' символов')
          }
          break
        case 'pattern':
          if (value && !ruleValue.test(value)) {
            return message || 'Неверный формат'
          }
          break
        case 'min':
          if (value !== '' && Number(value) < ruleValue) {
            return message || ('Минимальное значение: ' + ruleValue)
          }
          break
        case 'max':
          if (value !== '' && Number(value) > ruleValue) {
            return message || ('Максимальное значение: ' + ruleValue)
          }
          break
      }
    }
    return null  // нет ошибки
  }
}

// --- useForm аналог ---

function createForm(config = {}) {
  const { defaultValues = {}, mode = 'onSubmit' } = config

  // Храним значения полей (неконтролируемо — только для чтения)
  const fieldValues = { ...defaultValues }
  const fieldErrors = {}
  const fieldValidators = {}
  const touchedFields = new Set()
  let renderCount = 0

  function simulateRender(reason) {
    renderCount++
    console.log('  [Рендер #' + renderCount + '] причина:', reason)
  }

  function register(name, rules = {}) {
    // Сохраняем валидатор для поля
    fieldValidators[name] = createValidator(rules)
    fieldValues[name] = fieldValues[name] ?? defaultValues[name] ?? ''

    // Возвращаем "пропсы" для поля (как {...register('name')})
    return {
      name,
      defaultValue: fieldValues[name],

      // onChange: только записываем значение, БЕЗ ре-рендера
      onChange(value) {
        fieldValues[name] = value

        if (mode === 'onChange' || (mode === 'onTouched' && touchedFields.has(name))) {
          const error = fieldValidators[name]?.(value)
          const hadError = !!fieldErrors[name]
          fieldErrors[name] = error || undefined

          // Рендер только если статус ошибки ИЗМЕНИЛСЯ
          if (!!error !== hadError) {
            simulateRender('ошибка изменилась для ' + name)
          }
        }
      },

      // onBlur: валидация при потере фокуса
      onBlur() {
        touchedFields.add(name)
        if (mode === 'onBlur' || mode === 'onTouched' || mode === 'all') {
          const error = fieldValidators[name]?.(fieldValues[name])
          const hadError = !!fieldErrors[name]
          fieldErrors[name] = error || undefined

          if (!!error !== hadError) {
            simulateRender('blur-валидация для ' + name)
          }
        }
      }
    }
  }

  function handleSubmit(onValid, onInvalid) {
    return function submit() {
      console.log('
Submit: валидация всех полей...')

      let hasErrors = false
      for (const [name, validate] of Object.entries(fieldValidators)) {
        const error = validate(fieldValues[name])
        fieldErrors[name] = error || undefined
        if (error) {
          hasErrors = true
          console.log('  Ошибка в поле "' + name + '":', error)
        }
      }

      // Один рендер для показа всех ошибок (если они есть)
      if (hasErrors) {
        simulateRender('показ ошибок submit')
        console.log('Форма невалидна. Вызываем onInvalid')
        onInvalid?.(fieldErrors)
        return
      }

      console.log('Форма валидна! Данные:', JSON.stringify(fieldValues))
      onValid(fieldValues)
    }
  }

  function getFormState() {
    const errorCount = Object.values(fieldErrors).filter(Boolean).length
    return {
      errors: { ...fieldErrors },
      isValid: errorCount === 0,
      isDirty: JSON.stringify(fieldValues) !== JSON.stringify(defaultValues),
      renderCount,
    }
  }

  return { register, handleSubmit, getFormState, getValues: () => ({ ...fieldValues }) }
}

// --- Тест 1: Нулевые рендеры при вводе ---

console.log('=== Тест 1: Рендеры при вводе (mode: onSubmit) ===')
const form1 = createForm({ defaultValues: { name: '', email: '' }, mode: 'onSubmit' })
const nameField = form1.register('name', { required: 'Имя обязательно' })
const emailField = form1.register('email', { required: true, pattern: { value: /S+@S+/, message: 'Неверный email' } })

// Симулируем ввод — РЕНДЕРОВ НЕТ!
nameField.onChange('А')
nameField.onChange('Ал')
nameField.onChange('Але')
nameField.onChange('Алек')
nameField.onChange('Алексей')
emailField.onChange('alex@example.com')

console.log('Рендеров во время ввода:', form1.getFormState().renderCount)  // 0!

// --- Тест 2: Submit ---

console.log('
=== Тест 2: Submit с валидными данными ===')
const onValid = (data) => console.log('Успех! Данные:', JSON.stringify(data))
form1.handleSubmit(onValid)()
console.log('Рендеров после submit:', form1.getFormState().renderCount)  // 0 (нет ошибок)

// --- Тест 3: Submit с ошибками ---

console.log('
=== Тест 3: Submit с ошибками ===')
const form2 = createForm({ defaultValues: { name: '', age: 0 }, mode: 'onSubmit' })
form2.register('name', { required: 'Имя обязательно', minLength: { value: 2, message: 'Мин. 2 символа' } })
form2.register('age', { required: true, min: { value: 18, message: 'Должно быть 18+' } })

form2.handleSubmit(
  () => console.log('Успех'),
  (errors) => console.log('Ошибки:', JSON.stringify(errors))
)()

// --- Тест 4: mode: onBlur ---

console.log('
=== Тест 4: Режим onBlur ===')
const form3 = createForm({ defaultValues: { email: '' }, mode: 'onBlur' })
const emailField3 = form3.register('email', { pattern: { value: /S+@S+/, message: 'Неверный email' } })

emailField3.onChange('notanemail')  // нет рендера
console.log('Рендеров после onChange:', form3.getFormState().renderCount)  // 0

emailField3.onBlur()  // теперь валидация
console.log('Рендеров после onBlur:', form3.getFormState().renderCount)  // 1 (ошибка!)
console.log('Ошибка email:', form3.getFormState().errors.email)

React Hook Form: производительные формы

Проблема контролируемых форм

Стандартный React-подход к формам — контролируемые компоненты: каждый ввод в поле вызывает setState → ре-рендер компонента. Для формы с 20 полями это 20 ре-рендеров при каждом нажатии клавиши:

// Контролируемая форма: рендер при КАЖДОМ нажатии клавиши
function SlowForm() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  // ... ещё 18 полей

  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      {/* На каждый keydown: setState → рендер всех 20 полей */}
    </form>
  )
}

React Hook Form использует неконтролируемые компоненты: читает значения из DOM через refs только при необходимости (submit или валидация). Результат — нулевые ре-рендеры при вводе.

useForm: основа

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

function RegistrationForm() {
  const {
    register,      // регистрирует поля
    handleSubmit,  // обёртка для onSubmit
    formState: { errors, isSubmitting, isValid },
    watch,         // отслеживает значения полей
    setValue,      // программная установка значения
    reset,         // сброс формы
  } = useForm({
    defaultValues: {
      name: '',
      email: '',
      age: 18,
    }
  })

  const onSubmit = async (data) => {
    // data: { name, email, age } — только при submit!
    await saveUser(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('name', {
          required: 'Имя обязательно',
          minLength: { value: 2, message: 'Минимум 2 символа' },
        })}
      />
      {errors.name && <p>{errors.name.message}</p>}

      <input
        {...register('email', {
          required: 'Email обязателен',
          pattern: { value: /^[^@]+@[^@]+$/, message: 'Неверный email' },
        })}
      />
      {errors.email && <p>{errors.email.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Сохраняем...' : 'Зарегистрироваться'}
      </button>
    </form>
  )
}

Режимы валидации

useForm({
  mode: 'onSubmit',   // по умолчанию: только при submit
  mode: 'onBlur',     // при потере фокуса
  mode: 'onChange',   // при каждом изменении (как раньше)
  mode: 'onTouched',  // первый раз onBlur, потом onChange
  mode: 'all',        // всегда
})

Zod + zodResolver: типобезопасная валидация

import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'

const schema = z.object({
  name: z.string().min(2, 'Минимум 2 символа').max(50),
  email: z.string().email('Неверный email'),
  age: z.number().min(18, 'Должно быть 18+').max(120),
  password: z.string().min(8, 'Минимум 8 символов'),
  confirmPassword: z.string(),
}).refine(
  data => data.password === data.confirmPassword,
  { message: 'Пароли не совпадают', path: ['confirmPassword'] }
)

function Form() {
  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(schema),  // Zod проводит валидацию
  })
  // TypeScript автоматически знает типы полей из schema!
}

Controller: UI-библиотеки

Некоторые компоненты (Select из Material UI, DatePicker) не работают с нативным ref. Используйте Controller:

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

<Controller
  name="country"
  control={control}
  rules={{ required: 'Выберите страну' }}
  render={({ field }) => (
    <CustomSelect
      {...field}  // value, onChange, onBlur
      options={countries}
    />
  )}
/>

watch и setValue

const { watch, setValue } = useForm()

// Отслеживаем значение поля (вызывает ре-рендер)
const country = watch('country')
const [city, country] = watch(['city', 'country'])

// Программная установка
function fillFromProfile() {
  setValue('name', currentUser.name)
  setValue('email', currentUser.email, { shouldValidate: true })
}

// Подписка без ре-рендера (для side effects):
useEffect(() => {
  const subscription = watch((values) => {
    console.log('Форма изменилась:', values)
  })
  return () => subscription.unsubscribe()
}, [watch])

Вложенные поля и массивы

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

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

return (
  <div>
    {fields.map((field, i) => (
      <div key={field.id}>
        <input {...register('phoneNumbers.' + i + '.number')} />
        <button onClick={() => remove(i)}>Удалить</button>
      </div>
    ))}
    <button onClick={() => append({ number: '' })}>Добавить телефон</button>
  </div>
)

Примеры

Реализация концепции react-hook-form: неконтролируемые поля с ref, подписочная система валидации, сбор данных только при submit

// Строим упрощённый аналог react-hook-form:
// неконтролируемые поля + валидация по событиям + нулевые рендеры.

// --- Движок валидации ---

function createValidator(rules) {
  return function validate(value) {
    for (const [ruleName, ruleConfig] of Object.entries(rules)) {
      const ruleValue = typeof ruleConfig === 'object' ? ruleConfig.value : ruleConfig
      const message = typeof ruleConfig === 'object' ? ruleConfig.message : null

      switch (ruleName) {
        case 'required':
          if (ruleValue && (!value || value.trim() === '')) {
            return message || 'Поле обязательно'
          }
          break
        case 'minLength':
          if (value && value.length < ruleValue) {
            return message || ('Минимум ' + ruleValue + ' символов')
          }
          break
        case 'maxLength':
          if (value && value.length > ruleValue) {
            return message || ('Максимум ' + ruleValue + ' символов')
          }
          break
        case 'pattern':
          if (value && !ruleValue.test(value)) {
            return message || 'Неверный формат'
          }
          break
        case 'min':
          if (value !== '' && Number(value) < ruleValue) {
            return message || ('Минимальное значение: ' + ruleValue)
          }
          break
        case 'max':
          if (value !== '' && Number(value) > ruleValue) {
            return message || ('Максимальное значение: ' + ruleValue)
          }
          break
      }
    }
    return null  // нет ошибки
  }
}

// --- useForm аналог ---

function createForm(config = {}) {
  const { defaultValues = {}, mode = 'onSubmit' } = config

  // Храним значения полей (неконтролируемо — только для чтения)
  const fieldValues = { ...defaultValues }
  const fieldErrors = {}
  const fieldValidators = {}
  const touchedFields = new Set()
  let renderCount = 0

  function simulateRender(reason) {
    renderCount++
    console.log('  [Рендер #' + renderCount + '] причина:', reason)
  }

  function register(name, rules = {}) {
    // Сохраняем валидатор для поля
    fieldValidators[name] = createValidator(rules)
    fieldValues[name] = fieldValues[name] ?? defaultValues[name] ?? ''

    // Возвращаем "пропсы" для поля (как {...register('name')})
    return {
      name,
      defaultValue: fieldValues[name],

      // onChange: только записываем значение, БЕЗ ре-рендера
      onChange(value) {
        fieldValues[name] = value

        if (mode === 'onChange' || (mode === 'onTouched' && touchedFields.has(name))) {
          const error = fieldValidators[name]?.(value)
          const hadError = !!fieldErrors[name]
          fieldErrors[name] = error || undefined

          // Рендер только если статус ошибки ИЗМЕНИЛСЯ
          if (!!error !== hadError) {
            simulateRender('ошибка изменилась для ' + name)
          }
        }
      },

      // onBlur: валидация при потере фокуса
      onBlur() {
        touchedFields.add(name)
        if (mode === 'onBlur' || mode === 'onTouched' || mode === 'all') {
          const error = fieldValidators[name]?.(fieldValues[name])
          const hadError = !!fieldErrors[name]
          fieldErrors[name] = error || undefined

          if (!!error !== hadError) {
            simulateRender('blur-валидация для ' + name)
          }
        }
      }
    }
  }

  function handleSubmit(onValid, onInvalid) {
    return function submit() {
      console.log('
Submit: валидация всех полей...')

      let hasErrors = false
      for (const [name, validate] of Object.entries(fieldValidators)) {
        const error = validate(fieldValues[name])
        fieldErrors[name] = error || undefined
        if (error) {
          hasErrors = true
          console.log('  Ошибка в поле "' + name + '":', error)
        }
      }

      // Один рендер для показа всех ошибок (если они есть)
      if (hasErrors) {
        simulateRender('показ ошибок submit')
        console.log('Форма невалидна. Вызываем onInvalid')
        onInvalid?.(fieldErrors)
        return
      }

      console.log('Форма валидна! Данные:', JSON.stringify(fieldValues))
      onValid(fieldValues)
    }
  }

  function getFormState() {
    const errorCount = Object.values(fieldErrors).filter(Boolean).length
    return {
      errors: { ...fieldErrors },
      isValid: errorCount === 0,
      isDirty: JSON.stringify(fieldValues) !== JSON.stringify(defaultValues),
      renderCount,
    }
  }

  return { register, handleSubmit, getFormState, getValues: () => ({ ...fieldValues }) }
}

// --- Тест 1: Нулевые рендеры при вводе ---

console.log('=== Тест 1: Рендеры при вводе (mode: onSubmit) ===')
const form1 = createForm({ defaultValues: { name: '', email: '' }, mode: 'onSubmit' })
const nameField = form1.register('name', { required: 'Имя обязательно' })
const emailField = form1.register('email', { required: true, pattern: { value: /S+@S+/, message: 'Неверный email' } })

// Симулируем ввод — РЕНДЕРОВ НЕТ!
nameField.onChange('А')
nameField.onChange('Ал')
nameField.onChange('Але')
nameField.onChange('Алек')
nameField.onChange('Алексей')
emailField.onChange('alex@example.com')

console.log('Рендеров во время ввода:', form1.getFormState().renderCount)  // 0!

// --- Тест 2: Submit ---

console.log('
=== Тест 2: Submit с валидными данными ===')
const onValid = (data) => console.log('Успех! Данные:', JSON.stringify(data))
form1.handleSubmit(onValid)()
console.log('Рендеров после submit:', form1.getFormState().renderCount)  // 0 (нет ошибок)

// --- Тест 3: Submit с ошибками ---

console.log('
=== Тест 3: Submit с ошибками ===')
const form2 = createForm({ defaultValues: { name: '', age: 0 }, mode: 'onSubmit' })
form2.register('name', { required: 'Имя обязательно', minLength: { value: 2, message: 'Мин. 2 символа' } })
form2.register('age', { required: true, min: { value: 18, message: 'Должно быть 18+' } })

form2.handleSubmit(
  () => console.log('Успех'),
  (errors) => console.log('Ошибки:', JSON.stringify(errors))
)()

// --- Тест 4: mode: onBlur ---

console.log('
=== Тест 4: Режим onBlur ===')
const form3 = createForm({ defaultValues: { email: '' }, mode: 'onBlur' })
const emailField3 = form3.register('email', { pattern: { value: /S+@S+/, message: 'Неверный email' } })

emailField3.onChange('notanemail')  // нет рендера
console.log('Рендеров после onChange:', form3.getFormState().renderCount)  // 0

emailField3.onBlur()  // теперь валидация
console.log('Рендеров после onBlur:', form3.getFormState().renderCount)  // 1 (ошибка!)
console.log('Ошибка email:', form3.getFormState().errors.email)

Задание

Создай форму регистрации с валидацией в стиле react-hook-form. Компонент должен: использовать неконтролируемые поля через useRef; показывать ошибки только после submit; при успешной валидации вызывать onSubmit с данными формы. Заполни пропуски (???) для: получения значений из рефов, валидации email по паттерну, проверки совпадения паролей.

Подсказка

Для получения значения из рефа: nameRef.current.value. Для проверки email: !emailPattern.test(email). Для сравнения паролей: password !== confirm.

Загружаем среду выполнения...
Загружаем AI-помощника...