Как стать автором
Обновить
2016.59
Timeweb Cloud
То самое облако

React: интересная схема работы с формами

Уровень сложностиСложный
Время на прочтение14 мин
Количество просмотров16K


Hello, world!


В этой статье я хочу рассказать о схеме (назовем ее так) работы с формами в React, которая на сегодняшний день кажется мне наиболее эффективной. Эта схема предполагает использование React Hook Form для обработки форм и Zod для валидации пользовательских данных. Применение данной схемы имеет несколько существенных преимуществ по сравнению с использованием других решений или реализацией необходимого функционала вручную. Главными преимуществами являются минимизация количества шаблонного кода и автоматическое выведение типов (type inference).


Для тех, кого интересует только код, вот ссылка на соответствующий репозиторий.


Руководства, шпаргалки, вопросы и другие материалы по JavaScript, TypeScript, React, Next.js, Node.js, Express, Prisma, GraphQL, Docker и другим технологиям, а также Блог по веб-разработке.


Интересно? Тогда прошу под кат.


В качестве примера разработаем простую форму регистрации, содержащую следующие поля:


  • имя пользователя;
  • возраст;
  • адрес электронной почты;
  • пароль;
  • подтверждение пароля.

А также индикатор (чекбокс) принятия неких условий использования.


Все поля будут обязательными. О конкретных требованиях к каждому полю поговорим немного позднее.


Подготовка, настройка проекта и создание формы


Создаем шаблон проекта React с поддержкой TypeScript с помощью Vite (для работы с зависимостями я буду использовать Yarn):


# react-hook-form-zod - название проекта
# react-ts - используемый шаблон
yarn create vite react-hook-form-zod --template react-ts

Переходим в созданную директорию, устанавливаем зависимости и запускаем сервер для разработки:


cd react-hook-form-zod
yarn
yarn dev

Наша форма должна быть приятной глазу. Что бы нам использовать для ее стилизации? Как насчет Tailwind CSS? Устанавливаем эту библиотеку в качестве зависимости для разработки:


yarn add -D tailwindcss

Инициализируем ее:


npx tailwindcss init

Импортируем стили tailwind и определяем несколько переиспользуемых (reusable) стилей с помощью директивы @apply в файле src/index.css:


@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .title {
    @apply text-2xl text-center font-bold leading-tight tracking-tight text-gray-900;
  }
  .label {
    @apply block mb-2 text-sm font-medium text-gray-900 cursor-pointer;
  }
  .input {
    @apply bg-gray-50 border-none outline outline-1 outline-gray-300 text-gray-900 rounded-md w-full p-2.5 focus-visible:outline-2 focus-visible:outline-blue-500 placeholder:text-sm aria-[invalid="true"]:outline-red-500 aria-[invalid="true"]:outline-2;
    transition: outline-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
  }
  .error {
    @apply text-red-600 block text-sm absolute;
  }
  .btn {
    @apply text-white outline-none focus:ring-4 font-medium rounded-md text-sm px-5 py-2.5 text-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
  }
  .btn-primary {
    @apply bg-primary-500 hover:bg-primary-700 focus:ring-primary-300 disabled:bg-primary-500;
  }
  .btn-error {
    @apply bg-red-500 hover:bg-red-700 focus:ring-red-300 disabled:bg-red-500;
  }
}

Определяем файлы для обработки и расширяем дефолтную цветовую схему tailwind в файле tailwind.config.cjs:


/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#eff6ff',
          100: '#dbeafe',
          200: '#bfdbfe',
          300: '#93c5fd',
          400: '#60a5fa',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
          800: '#1e40af',
          900: '#1e3a8a'
        }
      }
    }
  },
  plugins: []
}

Наконец, определяем форму в файле src/App.tsx:


function App() {
  return (
    <section className='bg-gray-50'>
      <div className='flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0'>
        <div className='w-full bg-white rounded-lg shadow md:mt-0 sm:max-w-md xl:p-0'>
          <div className='p-6 space-y-4 md:space-y-6 sm:p-8'>
            <h1 className='title'>Создание аккаунта</h1>
            <form className='space-y-7'>
              <div className='mb-4'>
                <label htmlFor='username' className='label'>
                  Имя пользователя *
                </label>
                <input
                  type='text'
                  id='username'
                  className='input'
                  placeholder='Ваше имя'
                />
              </div>
              <div className='mb-4'>
                <label htmlFor='age' className='label'>
                  Возраст
                </label>
                <input
                  type='number'
                  id='age'
                  className='input'
                  placeholder='От 18 до 65 лет'
                />
              </div>
              <div>
                <label htmlFor='email' className='label'>
                  Адрес электронной почты *
                </label>
                <input
                  type='email'
                  id='email'
                  className='input'
                  placeholder='name@mail.com'
                />
              </div>
              <div>
                <label htmlFor='password' className='label'>
                  Пароль *
                </label>
                <input
                  type='password'
                  id='password'
                  placeholder='Не менее 6 символов'
                  className='input'
                />
              </div>
              <div>
                <label htmlFor='confirmPassword' className='label'>
                  Подтверждение пароля *
                </label>
                <input
                  type='password'
                  id='confirmPassword'
                  placeholder='Не менее 6 символов'
                  className='input'
                />
              </div>
              <div className='flex items-center relative'>
                <input
                  id='terms'
                  aria-describedby='terms'
                  type='checkbox'
                  className='w-4 h-4 border border-gray-300 bg-gray-50 accent-primary-500 focus:outline-2 focus:outline-primary-500 outline-none'
                />
                <label
                  htmlFor='terms'
                  className='font-light text-gray-500 text-sm ml-3 cursor-pointer select-none'
                >
                  Я принимаю{' '}
                  <a
                    className='font-medium text-primary-500 hover:text-primary-700 focus:text-primary-700 transition-colors outline-none'
                    href='#'
                  >
                    Условия использования
                  </a>
                </label>
              </div>
              <div className='flex gap-5 justify-center pt-2'>
                <button
                  type='submit'
                  className='btn btn-primary'
                >
                  Создать аккаунт
                </button>
                <button
                  type='button'
                  className='btn btn-error'
                >
                  Очистить поля
                </button>
              </div>
            </form>
          </div>
        </div>
      </div>
    </section>
  )
}

export default App

Результат:





Кроме названных выше полей, форма содержит кнопки для отправки формы ("Создать аккаунт") и очистки всех полей ("Очистить поля"). Она выглядит прилично (по крайней мере, на мой "вкус и цвет" ?), но пока не делает ничего полезного: нажатие "Создать аккаунт" просто перезагружает страницу. Давайте это исправим. Первое, что необходимо для этого сделать — определить схему валидации пользовательских данных с помощью zod.


Определение схемы валидации


Устанавливаем zod:


yarn add zod

Важно: для работы zod требуется TypeScript 4.5+ и строгий режим проверки типов:


// tsconfig.json
{
  "compilerOptions": {
    "strict": true
  }
}

Определение схемы с помощью zod похоже на определение схемы с помощью таких инструментов, как Joi, Yup, io-ts и др. Однако zod позволяет создавать схемы меньшими усилиями и лучше справляется с выводом типов.


Как вы думаете, какую библиотеку zod использует для валидации? Конечно же, validator.js.


Подумаем о требованиях к полям формы. Как насчет следующего:


  • длина имени пользователя должна находиться в диапазоне от 2 до 20 символов
  • возраст пользователя должен находиться в диапазоне от 18 до 65 (в реальном приложении это поле наверняка будет опциональным, но так неинтересно)
  • email должен быть… email'ом ?
  • длина пароля должна быть не менее 6 символов (в реальном приложении требования наверняка будут более жесткими в целях безопасности, но нам бояться некого)
  • пароли, введенные в полях "Пароль" и "Подтверждение пароля", должны совпадать

Как отмечалось ранее, все поля формы являются обязательными, включая индикатор принятия условий использования, а также сам объект данных формы.


Определяем схему в App.tsx перед определением компонента (я постарался использовать максимальное количество возможностей, предоставляемых zod):


import { z } from 'zod'

const formSchema = z
  // данные формы - объект
  .object({
    // имя пользователя - строка (один из примитивов)
    // https://github.com/colinhacks/zod#primitives
    // https://github.com/colinhacks/zod#strings
    username: z
      .string()
      // минимальное количество символов
      // второй аргумент - сообщение об ошибке
      .min(2, { message: 'Имя пользователя слишком короткое' })
      // максимальное количество символов
      // сокращенный вариант сообщения об ошибке
      .max(20, 'Имя пользователя слишком длинное')
      // преобразование данных после валидации
      // https://github.com/colinhacks/zod#transform
      // приводим символы к нижнему регистру и заменяем проблемы на `_`
      .transform((v) => v.toLowerCase().replace(/\s+/g, '_')),
    age: z
      // возраст - число
      // https://github.com/colinhacks/zod#numbers
      .number()
      // так можно сделать поле опциональным
      // .optional()
      // кастомная валидация поля
      // https://github.com/colinhacks/zod#refine
      .refine((v) => v > 17 && v < 66, {
        message: 'Возраст за пределами допустимого диапазона',
      }),
    email: z.string().email('Некорректный email'),
    password: z.string().min(6, 'Пароль слишком короткий'),
    confirmPassword: z.string().min(6, 'Повторите пароль'),
    // особый тип - литерал, может (должен) иметь только одно значение
    terms: z.literal(true, {
      // ошибки литералов обрабатываются с помощью карт ошибок
      // https://github.com/colinhacks/zod/blob/master/ERROR_HANDLING.md#customizing-errors-with-zoderrormap
      errorMap: () => ({ message: 'Примите условия использования' }),
    }),
  })
  // кастомная валидация формы - всего объекта
  .refine((data) => data.password === data.confirmPassword, {
    // необходимо указать путь - название поля с ошибкой
    // https://github.com/colinhacks/zod#customize-error-path
    path: ['confirmPassword'],
    message: 'Введенные пароли не совпадают',
  })

Данная схема позволяет не только валидировать пользовательские данные (для чего она, собственно, и предназначена), но и выводить типы этих данных легким движением руки:


type FormSchema = z.infer<typeof formSchema>

Далее возникает закономерный вопрос: как применить эту схему для валидации формы? Поскольку для обработки формы будет использоваться react-hook-form (далее — RHF), эту задачу можно свести к интеграции zod и RHF. Решение гораздо проще, чем может показаться, но давайте сначала разберемся с RHF.


Обработка формы


Устанавливаем RHF:


yarn add react-hook-form

Основным хуком RHF является useForm(), который принимает много опциональных пропов (нас интересует только один из них, но об этом позже). Он не требует передачи начальных значений полей формы, поскольку по умолчанию поля формы являются неуправляемыми (uncontrolled) (к слову, в новой документации React больше не содержится рекомендации по использованию управляемых инпутов).


useForm() возвращает объект, содержащий несколько свойств, из которых нас интересуют следующие:


  • register — метод для регистрации поля формы
  • handleSubmit — метод отправки формы
  • reset — метод сброса состояния формы (очистки всех полей)
  • setFocus — метод программной установки фокуса на поле
  • formState — объект состояния формы

Что касается последнего, то нас интересуют следующие свойства:


  • isDirty — принимает значение true после того, как пользователь изменил значение любого поля
  • isSubmitting — имеет значение true в процессе отправки формы
  • errors — объект с ошибками полей

Редактируем App.tsx:


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

// Схема

type FormSchema = z.infer<typeof formSchema>

function App() {
  const {
    register,
    handleSubmit,
    reset,
    setFocus,
    formState: { isDirty, isSubmitting, errors },
  } = useForm<FormSchema>()

  // обработчик отправки формы
  const onSubmit: SubmitHandler<FormSchema> = (data) => {
    // просто выводим данные в консоль
    console.log(data)
    // сбрасываем состояние формы (очищаем поля)
    reset()
  }

  useEffect(() => {
    // устанавливаем фокус на первое поле (имя пользователя) после монтирования компонента
    setFocus('username')
  }, [])

  // ...
}

Регистрируем обработчик отправки формы, поля формы, рендерим ошибки (при наличии), а также блокируем кнопки до начала работы пользователя с формой и на период обработки отправки формы:


{/* Регистрируем обработчик отправки формы */}
<form className='space-y-7' onSubmit={handleSubmit(onSubmit)}>
  <div className='mb-4'>
    <label htmlFor='username' className='label'>
      Имя пользователя *
    </label>
    <input
      {/* Регистрируем поле формы */}
      {/* Распаковываем объект, возвращаемый `register()`, передавая инпуту пропы `onChange`, `onBlur`, `ref` и `name` */}
      {...register('username')}
      type='text'
      id='username'
      className='input'
      placeholder='Ваше имя'
      // значение этого свойства определяется наличием ошибки
      aria-invalid={errors.username ? 'true' : 'false'}
    />
    {/* Рендерим ошибку при наличии */}
    {errors.username && (
      <span role='alert' className='error'>
        {errors.username?.message}
      </span>
    )}
  </div>
  <div className='mb-4'>
    <label htmlFor='age' className='label'>
      Возраст
    </label>
    <input
      {...register('age', {
        // по умолчанию возвращается строка (несмотря на `type="number"`),
        // а для успешного прохождения будущей валидации требуется число
        setValueAs: (v) => Number(v),
      })}
      type='number'
      id='age'
      className='input'
      placeholder='От 18 до 65 лет'
      aria-invalid={errors.age ? 'true' : 'false'}
    />
    {errors.age && (
      <span role='alert' className='error'>
        {errors.age?.message}
      </span>
    )}
  </div>
  <div>
    <label htmlFor='email' className='label'>
      Адрес электронной почты *
    </label>
    <input
      {...register('email')}
      type='email'
      id='email'
      className='input'
      placeholder='name@mail.com'
      aria-invalid={errors.email ? 'true' : 'false'}
    />
    {errors.email && (
      <span role='alert' className='error'>
        {errors.email?.message}
      </span>
    )}
  </div>
  <div>
    <label htmlFor='password' className='label'>
      Пароль *
    </label>
    <input
      {...register('password')}
      type='password'
      id='password'
      placeholder='Не менее 6 символов'
      className='input'
      aria-invalid={errors.password ? 'true' : 'false'}
    />
    {errors.password && (
      <span role='alert' className='error'>
        {errors.password?.message}
      </span>
    )}
  </div>
  <div>
    <label htmlFor='confirmPassword' className='label'>
      Подтверждение пароля *
    </label>
    <input
      {...register('confirmPassword')}
      type='password'
      id='confirmPassword'
      placeholder='Не менее 6 символов'
      className='input'
      aria-invalid={errors.confirmPassword ? 'true' : 'false'}
    />
    {errors.confirmPassword && (
      <span role='alert' className='error'>
        {errors.confirmPassword?.message}
      </span>
    )}
  </div>
  <div className='flex items-center relative'>
    <input
      {...register('terms')}
      id='terms'
      aria-describedby='terms'
      type='checkbox'
      className='w-4 h-4 border border-gray-300 bg-gray-50 accent-primary-500 focus:outline-2 focus:outline-primary-500 outline-none'
      aria-invalid={errors.terms ? 'true' : 'false'}
    />
    <label
      htmlFor='terms'
      className='font-light text-gray-500 text-sm ml-3 cursor-pointer select-none'
    >
      Я принимаю{' '}
      <a
        className='font-medium text-primary-500 hover:text-primary-700 focus:text-primary-700 transition-colors outline-none'
        href='#'
      >
        Условия использования
      </a>
    </label>
    {errors.terms && (
      <span className='error top-5'>{errors.terms?.message}</span>
    )}
  </div>
  <div className='flex gap-5 justify-center pt-2'>
    <button
      type='submit'
      className='btn btn-primary'
      // блокируем кнопку
      disabled={!isDirty || isSubmitting}
    >
      Создать аккаунт
    </button>
    <button
      type='button'
      className='btn btn-error'
      disabled={!isDirty || isSubmitting}
      // нажатие этой кнопки приводит к сбросу состояния формы
      // типы `onClick()` и `reset()` несовместимы,
      // поэтому нельзя сделать `onClick={reset}`
      onClick={() => reset()}
    >
      Очистить поля
    </button>
  </div>
</form>

Мы проделали много работы, но результат работы формы (для конечного пользователя) остался прежним, не считая вывода данных формы в консоль и блокировки кнопок. Почему? Потому что отсутствует валидация полей. Давайте это исправим.


Справедливости ради, следует отметить, что RHF, конечно же, предоставляет достаточно широкие возможности по валидации полей формы, но мы хотим большего ?


Интеграция Zod и Rect Hook Form и проверка валидации


Одним из пропов, принимаемых useForm(), является проп resolver, позволяющий использовать внешнюю библиотеку для валидации данных. Интеграция RHF с такими библиотеками выполняется с помощью "резолверов" из пакета @hookform/resolvers. Устанавливаем его:


yarn add @hookform/resolvers

Одним из резолверов является zodResolver:


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

В качестве первого параметра резолвер принимает схему валидации:


zodResolver(formSchema)

Таким образом, для интеграции zod и RHF достаточно вызвать useForm() следующим образом:


useForm<FormSchema>({ resolver: zodResolver(formSchema) })

Полный код формы
import { z } from 'zod'
import { SubmitHandler, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'

const formSchema = z
  .object({
    username: z
      .string()
      .min(2, { message: 'Имя пользователя слишком короткое' })
      .max(20, 'Имя пользователя слишком длинное')
      .transform((v) => v.toLowerCase().replace(/\s+/g, '_')),
    age: z
      .number()
      // .optional()
      .refine((v) => v > 17 && v < 66, {
        message: 'Возраст за пределами допустимого диапазона',
      }),
    email: z.string().email('Некорректный email'),
    password: z.string().min(6, 'Пароль слишком короткий'),
    confirmPassword: z.string().min(6, 'Повторите пароль'),
    terms: z.literal(true, {
      errorMap: () => ({ message: 'Примите условия использования' }),
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    path: ['confirmPassword'],
    message: 'Введенные пароли не совпадают',
  })

type FormSchema = z.infer<typeof formSchema>

function App() {
  const {
    register,
    handleSubmit,
    reset,
    setFocus,
    formState: { isDirty, isSubmitting, errors },
  } = useForm<FormSchema>({ resolver: zodResolver(formSchema) })

  const onSubmit: SubmitHandler<FormSchema> = (data) => {
    console.log(data)
    reset()
  }

  useEffect(() => {
    setFocus('username')
  }, [])

  return (
    <section className='bg-gray-50'>
      <div className='flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0'>
        <div className='w-full bg-white rounded-lg shadow md:mt-0 sm:max-w-md xl:p-0'>
          <div className='p-6 space-y-4 md:space-y-6 sm:p-8'>
            <h1 className='title'>Создание аккаунта</h1>
            <form className='space-y-7' onSubmit={handleSubmit(onSubmit)}>
              <div className='mb-4'>
                <label htmlFor='username' className='label'>
                  Имя пользователя *
                </label>
                <input
                  {...register('username')}
                  type='text'
                  id='username'
                  className='input'
                  placeholder='Ваше имя'
                  aria-invalid={errors.username ? 'true' : 'false'}
                />
                {errors.username && (
                  <span role='alert' className='error'>
                    {errors.username?.message}
                  </span>
                )}
              </div>
              <div className='mb-4'>
                <label htmlFor='age' className='label'>
                  Возраст
                </label>
                <input
                  {...register('age', {
                    setValueAs: (v) => Number(v),
                  })}
                  type='number'
                  id='age'
                  className='input'
                  placeholder='От 18 до 65 лет'
                  aria-invalid={errors.age ? 'true' : 'false'}
                />
                {errors.age && (
                  <span role='alert' className='error'>
                    {errors.age?.message}
                  </span>
                )}
              </div>
              <div>
                <label htmlFor='email' className='label'>
                  Адрес электронной почты *
                </label>
                <input
                  {...register('email')}
                  type='email'
                  id='email'
                  className='input'
                  placeholder='name@mail.com'
                  aria-invalid={errors.email ? 'true' : 'false'}
                />
                {errors.email && (
                  <span role='alert' className='error'>
                    {errors.email?.message}
                  </span>
                )}
              </div>
              <div>
                <label htmlFor='password' className='label'>
                  Пароль *
                </label>
                <input
                  {...register('password')}
                  type='password'
                  id='password'
                  placeholder='Не менее 6 символов'
                  className='input'
                  aria-invalid={errors.password ? 'true' : 'false'}
                />
                {errors.password && (
                  <span role='alert' className='error'>
                    {errors.password?.message}
                  </span>
                )}
              </div>
              <div>
                <label htmlFor='confirmPassword' className='label'>
                  Подтверждение пароля *
                </label>
                <input
                  {...register('confirmPassword')}
                  type='password'
                  id='confirmPassword'
                  placeholder='Не менее 6 символов'
                  className='input'
                  aria-invalid={errors.confirmPassword ? 'true' : 'false'}
                />
                {errors.confirmPassword && (
                  <span role='alert' className='error'>
                    {errors.confirmPassword?.message}
                  </span>
                )}
              </div>
              <div className='flex items-center relative'>
                <input
                  {...register('terms')}
                  id='terms'
                  aria-describedby='terms'
                  type='checkbox'
                  className='w-4 h-4 border border-gray-300 bg-gray-50 accent-primary-500 focus:outline-2 focus:outline-primary-500 outline-none'
                  aria-invalid={errors.terms ? 'true' : 'false'}
                />
                <label
                  htmlFor='terms'
                  className='font-light text-gray-500 text-sm ml-3 cursor-pointer select-none'
                >
                  Я принимаю{' '}
                  <a
                    className='font-medium text-primary-500 hover:text-primary-700 focus:text-primary-700 transition-colors outline-none'
                    href='#'
                  >
                    Условия использования
                  </a>
                </label>
                {errors.terms && (
                  <span className='error top-5'>{errors.terms?.message}</span>
                )}
              </div>
              <div className='flex gap-5 justify-center pt-2'>
                <button
                  type='submit'
                  className='btn btn-primary'
                  disabled={!isDirty || isSubmitting}
                >
                  Создать аккаунт
                </button>
                <button
                  type='button'
                  className='btn btn-error'
                  disabled={!isDirty || isSubmitting}
                  onClick={() => reset()}
                >
                  Очистить поля
                </button>
              </div>
            </form>
          </div>
        </div>
      </div>
    </section>
  )
}

export default App

Проверим, насколько правильно выполняется валидация.


Принимаем условия использования и пытаемся отправить форму:





По умолчанию после первой отправки формы сообщения об ошибках отображаются до тех пор, пока пользователь не введет корректные данные (это можно изменить):





После валидации имя пользователя форматируется согласно нашим требованиям:





Демо приложения:



Избавляет ли нас такая серьезная валидация данных на клиенте от необходимости их повторной валидации на сервере? Нет. Почему? Потому что я могу отключить JavaScript, добавить форме атрибут action с адресом сервера, например, /api/auth/register и отправить на сервер что угодно. Например, вот как будет выглядеть строка запроса, если отправить пустую форму (атрибут disabled кнопки "Создать аккаунт" тоже можно убрать):


http://localhost:5173/api/auth/register?username=&age=&email=&password=&confirmPassword=

Когда использовать такую схему работы с формами? Это зависит от многих факторов. С одной стороны, нужно потратить какое-то время на изучение zod и RHF. С другой стороны, хорошо владея данными инструментами, можно экономить кучу времени при решении типичных задач, связанных с обработкой форм. Так что решайте сами: инвестиции в себя (чем, безусловно, является изучение новых технологий) всегда оправдываются ?


Надеюсь, вы узнали что-то новое и не зря потратили время.


Happy coding!




Теги:
Хабы:
Всего голосов 16: ↑15 и ↓1+17
Комментарии5

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud

Истории