Создание простого хука для работы с формами в React на основе `zod`
В мире фронтенд-разработки управление состоянием форм играет важную роль, особенно когда дело касается валидации, отправки данных и управления ошибками. Одним из популярных решений является библиотека react-hook-form
, которая позволяет эффективно работать с формами, минимизируя количество ререндеров и упрощая взаимодействие с React-компонентами.
Однако что, если нам нужна более лёгкая или кастомизированная версия этой библиотеки? В этой статье мы рассмотрим, как создать простую самописную альтернативу react-hook-form
, разберём её основные принципы работы и продемонстрируем, как можно управлять состоянием формы с минимальными затратами кода.
Этот процесс не только поможет лучше понять, как работают существующие решения, но и даст вам возможность адаптировать форму под свои потребности, избегая ненужного усложнения и лишних зависимостей.
Теперь давайте реализуем простую версию такой библиотеки, используя встроенный API FormData
для сбора данных формы и zod
для их валидации.
В конечном итоге будет форма из следующих полей:
Логин* —
string
Пароль* —
string
Повторить пароль* —
string
должен совпадать спароль
Возраст* —
number
минимум 18 максимум 60, доступен ввод только чиселДень рождения —
string
в формате ISOУмения* —
string[]
минимум 2Файл* —
File
только.png
Запомни меня —
boolean

Полный пример формы на CodeSandbox
CodeSandbox
Для начала давайте начнем с простых компонентов. Сверстаем компоненты Input
, InputNumber
, InputCheckbox
export const Input: FC> = (props) => {
return (
);
};
export const InputNumber: FC> = ({
onChange,
...props
}) => (
{
e.currentTarget.value = e.currentTarget.value.replace(/\D/g, '');
onChange?.(e);
}}
{...props}
/>
);
type InputCheckboxProps = InputHTMLAttributes & {
label?: string;
};
export const InputCheckbox: FC = ({ label, ...props }) => {
const id = useId();
return (
<>
);
};
Компонент с формой (пока ещё не финальный вариант. Без валидации и без отображения ошибок. Финальный вариант будет в конце статьи)
export const FormExample = () => {
const onSubmit = (e: React.ChangeEvent) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData.entries());
console.log(data);
};
return (
Итак, давайте рассмотрим его работу. При нажатии на кнопку Отправить
, получим в консоли следующий объект.
{
login: "password",
password: "123",
repeatPassword: "123",
age: "50",
birthday: "2025-03-12",
rememberMe: "on",
}
Если оставить все поля пустыми
При использовании FormData
стоит учесть несколько особенностей. FormData
содержит в себе ключ — значение (тип может быть только string
или Blob
). В нашем случае мы получили объект в котором все поля типа string
, что не совсем корректно. Эти особенности FormData
можно нивелировать с помощью библиотеки zod
, которая предоставляет методы для валидации полей, плюсом может преобразовать поля в нужный нам тип.
Вот zod
схема, которая позволит на валидировать и трансформировать поля нужным нам образом:
/*
FormData возвращает строку. Даже если поле будет пустым, то вернётся пустая строка.
Этот препроцессор заменяет пустую строку на undefined для удобства в дальнейшей валидации
*/
export const zodFormString = (schema: T) =>
z.preprocess((value) => (value ? String(value) : undefined), schema);
/*
Заменяет строку на number или undefined
*/
export const zodFormNumber = (schema: T) =>
z.preprocess((value) => {
const num = Number(value);
return isNaN(num) ? undefined : num;
}, schema);
/*
Заменяет строку на ISOString или undefined
*/
export const zodFormDate = (schema: T) =>
z.preprocess(
(value) => (value ? new Date(String(value)).toISOString() : undefined),
schema,
);
export const zodFormCheckbox = z
.string({ message: 'Обязательное поле' })
.transform((v) => v === 'on');
const schema = z
.object({
login: zodFormString(z.string({ message: 'Введите валидный логин' })),
password: zodFormString(z.string({ message: 'Введите валидный пароль' })),
repeatPassword: zodFormString(
z.string({ message: 'Введите валидный пароль' }),
),
age: zodFormNumber(
z
.number({ message: 'Обязательное поле' })
.min(18, 'Минимум 18 лет')
.max(60, 'Максимум 60 лет'),
),
date: zodFormDate(z.string().optional()),
rememberMe: zodFormCheckbox.optional(),
})
.refine((v) => v.password === v.repeatPassword, {
message: 'Пароли должны совпадать',
path: ['repeatPassword'],
});
Тут представлены несколько препроцессоров, которые позволяет модифицировать входные данные и придать им необходимый формат перед передачей их на валидацию. Объясню на примере zodFormNumber
. FormData
возвращает по умолчанию ""
, даже если поле было не запалено. Нам же нужно, что бы при пустом поле оно возвращало undefined
, а при заполненном поле приводило его к типу number
если это возможно. Этими преобразованиями и занимается препроцессор zodFormNumber
. Далее в схеме указываем, что поле age
является обязательным, и устанавливаем ему ограничения в 18 — 60
Далее в form
меняем пропс onSubmit
на следующий:
При нажатии на кнопку Отправить
если форма валидна то мы получим объект нужного нам формата. Если форма не валидна в консоли отобразятся объект с массивом из ошибок, которые мы сможем отображать под инпутами
{
age: ['Минимум 18 лет'],
login: ['Введите валидный логин'],
password: ['Введите валидный пароль'],
repeatPassword: ['Введите валидный пароль'],
}
Хук useForm
Напишем кастомный хук useForm
. Который может принимать набор значений по умолчанию defaultState
, саму схему валидации schema
и 2 функции onSubmit
onError
. На практике же в onSubmit
мы получаем валидные значения и можем отправить их на сервер. В onError
можем дополнительно обработать ошибки и показать например модалку с предупреждением.
type UseFormConfig = {
schema: z.Schema;
onSubmit?: (
values: TOutput,
form: FormEvent,
) => Promise;
onError?: (err: unknown, form: FormEvent) => void;
defaultState?: Partial;
};
const isZodObject = (schema: z.Schema): schema is z.AnyZodObject =>
'shape' in schema;
const isZodEffect = (
schema: z.Schema,
): schema is z.ZodEffects =>
'typeName' in schema._def &&
schema._def.typeName === z.ZodFirstPartyTypeKind.ZodEffects;
export const useForm = ({
schema,
onSubmit,
defaultState = {},
onError,
}: UseFormConfig) => {
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState>>({});
const formOnSubmit: FormEventHandler = async (e) => {
e.preventDefault();
setIsLoading(true);
const formData = new FormData(e.currentTarget);
try {
const data = await schema.parseAsync(
Object.fromEntries(formData.entries()),
);
await onSubmit?.(data, e);
if (Object.keys(errors).length) {
setErrors({});
}
} catch (err) {
if (err instanceof z.ZodError) {
setErrors(err.formErrors.fieldErrors);
}
onError?.(err, e);
} finally {
setIsLoading(false);
}
};
const getErrorLabel = (name: keyof TOutput) => errors[name]?.join('. ');
const hasError = (name: keyof TOutput) => Boolean(errors[name]?.length);
const getRequired = (name: keyof TOutput): boolean => {
if (isZodObject(schema)) {
return !schema.shape[name].isOptional();
}
if (isZodEffect(schema)) {
return !schema._def.schema.shape[name].isOptional();
}
return false;
};
const register = (
name: keyof TOutput,
): InputHTMLAttributes => ({
defaultChecked: Boolean(defaultState[name]),
defaultValue: defaultState[name] ? String(defaultState[name]) : undefined,
name: name as string,
required: getRequired(name),
/*
После нажатия на submit появляются не валидные поля. Валидными они остаются до тех пор пока
снова не будет нажада кнопка submit. Такое поведение не очень корректно
Нужно что бы поле проходило проверку валдации в момент редактирования
*/
onChange: (e) => {
const form = e.currentTarget.form;
if (hasError(name) && form) {
schema
.parseAsync(Object.fromEntries(new FormData(form).entries()))
.then(() => setErrors({}))
.catch((err) => {
const hasErrorChanges =
Object.keys(errors).length !==
Object.keys(err.formErrors.fieldErrors).length;
if (err instanceof z.ZodError && hasErrorChanges) {
setErrors(err.formErrors.fieldErrors);
}
});
}
},
});
return {
onSubmit: formOnSubmit,
getErrorLabel,
hasError,
register,
isLoading,
getRequired,
};
};
Напишем компонент обертку для полей. В которой будут отображаться ошибки, заголовок поля, и маркер обозначения обязательности поля.
type FormField = {
form: ReturnType>;
name: keyof T;
label?: string;
};
export function FormField({
form,
label,
children,
name,
}: PropsWithChildren>) {
const hasError = form.hasError(name);
return (
{label && (
{label}
{form.getRequired(name) ? * : ''}
)}
{children}
{hasError && (
{form.getErrorLabel(name)}
)}
);
}
Используем этот хук в компоненте с формой:
type UserFormData = z.infer;
export const FormExample = () => {
const form = useForm({
schema: schema,
onSubmit: async (value) => console.log(value),
onError: console.log,
defaultState: {
rememberMe: true,
},
});
const { register, onSubmit, isLoading } = form;
return (
);
};
В большинстве случаев таких форм будет достаточно. Самое интересное что с таким подходом соблюдается полная типизация в функцию register
не получится передать название поля, которого нет в схеме и есть typescript
подсказки по другими полям в форме
Кастомные поля
Далее добавим кастомное поле с возможностью выбора множества тегов.
type TabSelectOption = {
value: string;
label: string;
};
type TabSelectProps = InputHTMLAttributes & {
options: TabSelectOption[];
};
export const TabSelect: FC = ({
options,
defaultValue,
...props
}) => {
const ref = useRef(null);
const [selectedValue, setSelectedValue] = useState>(() =>
defaultValue ? new Set(JSON.parse(defaultValue as string)) : new Set(),
);
const selectHandler = (value: string) => {
return () => {
setSelectedValue((prev) => {
const newSet = new Set(prev);
if (newSet.has(value)) {
newSet.delete(value);
} else {
newSet.add(value);
}
if (ref.current) {
ref.current.value = JSON.stringify(Array.from(newSet.keys()));
props.onChange?.({
currentTarget: ref.current,
target: ref.current,
} as React.ChangeEvent);
}
return newSet;
});
};
};
return (
<>
{options.map((option) => (
))}
);
};
Из-за особенности FormData
все поля в форме должны иметь , иначе они просто не попадут в объект
FormData
. Для этого мы создаем скрытый инпут и с помощью
useRef
прокидываем ему референс ссылку для того что бы установить ему value
которое будет JSON
строкой из состоящей из массива тегов. Напоминаю в FormData
может содержаться только string
или Blob
, для этого мы и преобразуем массив тегов в JSON
По такому принципу можно создавать поля любой сложности
В схему добавим соответсвующее поле skills
:
const schema = z
.object({
...
skills: zodFormString(
z
.string()
.default('[]')
.transform((v) => JSON.parse(v) as string[])
.refine((v) => v.length >= 2, { message: 'Минимум 2 скилла' }),
),
И прокинем в форму этот компонент:
Контролируемые и неконтролируемые компоненты
Как вы могли заметить в данной реализации мы используем неконтролируемые компоненты, так как это позволяет:
Избежать лишних ререндеров, так как значения не хранятся в state.
Использовать FormData для удобного сбора данных.
Упростить интеграцию с нативными HTML-формами.
Если же вы не знакомы с этой концепцией, кратко её опишу:
В React существует два подхода к работе с формами: контролируемые и неконтролируемые компоненты.
Контролируемые компоненты управляют значениями полей через состояние
useState
. Каждое изменение поля вызывает ререндер компонента, что позволяет точно контролировать ввод.Неконтролируемые компоненты используют
ref
илиFormData
для получения значений напрямую из DOM-элементов, а не хранят их в стейте. Это снижает количество ререндеров и делает работу с формами более производительной.
Полезные ссылки
Документация zod
Полный пример формы на GitHub
CodeSandbox