Hello, world!
В этой статье я хочу рассказать о схеме (назовем ее так) работы с формами в React, которая на сегодняшний день кажется мне наиболее эффективной. Эта схема предполагает использование React Hook Form для обработки форм и Zod для валидации пользовательских данных. Применение данной схемы имеет несколько существенных преимуществ по сравнению с использованием других решений или реализацией необходимого функционала вручную. Главными преимуществами являются минимизация количества шаблонного кода и автоматическое выведение типов (type inference).
Для тех, кого интересует только код, вот ссылка на соответствующий репозиторий.
Интересно? Тогда прошу под кат.
В качестве примера разработаем простую форму регистрации, содержащую следующие поля:
- имя пользователя;
- возраст;
- адрес электронной почты;
- пароль;
- подтверждение пароля.
А также индикатор (чекбокс) принятия неких условий использования.
Все поля будут обязательными. О конкретных требованиях к каждому полю поговорим немного позднее.
Подготовка, настройка проекта и создание формы
Создаем шаблон проекта 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!