리액트에서 단순한 form은 쉽게 관리할 수 있지만 대규모 form으로 발전하면 다음과 같은 문제가 발생한다.
이를 해결하기 위해 React Hook Form과 Zod를 활용하면 다음과 같은 장점이 있다.
watch
기능으로 실시간 상태 추적이 가능하여 제어 컴포넌트의 장점을 함께 제공한다.useForm
같은 Hook 기반 API로 직관적이고 간결한 코드 작성이 가능하다.register
를 사용해 필드 값과 유효성 검사를 자동으로 관리한다.Material-UI
같은 UI 라이브러리나 shadcn/ui
같은 component collection과 쉽게 통합 가능하다.
공식 문서
리액트 내부에서 값이 제어되는 컴포넌트를 의미하며, 리액트의 상태를 통해 입력 필드의 값을 관리하는 방식이다.
DOM이 직접 입력값을 괸리하는 방식으로 useRef
를 활용한다.
useRef
는 heap 영역에 저장되는 일반적인 자바스크립트 객체로, 애플리케이션이 종료되거나 가비지 컬렉팅될 때까지 참조할 때마다 같은 메모리 값을 가진다. 값이 변경되어도 같은 메모리 주소를 가지고 있기 때문에 리액트는 변경사항을 감지할 수 없어 리렌더링하지 않는다. 이를 통해 렌더링 횟수를 줄이고 성능을 최적화할 수 있다.
Zod는 타입스크립트를 우선으로 설계된 스키마(Schema) 선언 및 유효성 검사 라이브러리이다.
데이터 스키마를 선언적으로 정의하여 사용하는데, 여기서 스키마란 데이터의 형태, 데이터의 타입 그리고 데이터가 충족해야 할 조건들을 지정한다.
해당 스키마를 기준으로 Zod는 주어진 데이터를 검증하고 검증에 실패하면 에러를 리턴한다. 이를 통해 데이터의 무결성을 유지하고 예상치 못한 데이터 구조로 발생하는 에러를 방지할 수 있다.
parse()
와 safeParse()
메서드로 데이터를 검증한다.@hookform/resolvers
라이브러리를 사용하여 React Hook Form과 Zod의 스키마를 연결한다.
superRefine()
메서드를 사용하면 여러 이슈를 추가하여 조건부 검사를 진행할 수 있다.import { z } from 'zod';
export const RegisterSchema = z.object({
productName: z.string().superRefine((value, ctx) => {
const name = value.replaceAll(' ', '');
if (name.length === 0 || name.length < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '제목은 공백을 제외하고 2자 이상 입력해 주세요.',
});
}
if (name.length > 30) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '제목은 최대 30자 이하로 입력해 주세요.',
});
}
}),
minPrice: z.string().superRefine((value, ctx) => {
const num = Number(value.replace(/[^\d]/g, ''));
if (num < 1000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '최소 1000원 이상 입력해 주세요.',
});
}
if (num > 2_000_000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '2,000,000원 이하로 입력해 주세요.',
});
}
if (num % 1000 !== 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '1000원 단위로 입력해 주세요.',
});
}
}),
description: z
.string()
.superRefine((value, ctx) => {
const name = value.replaceAll(' ', '');
const newLineCount = (value.match(/\n/g) || []).length;
if (name.length > 1000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '상품 설명은 최대 1000자 이하로 입력해 주세요.',
});
}
if (newLineCount > 10) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '상품 설명은 줄바꿈을 10개 이하로 입력해 주세요.',
});
}
})
.or(z.literal('')),
});
재사용가능한 컴포넌트로 분리하여 재사용성을 높인다.
import { ReactElement } from 'react';
import { Control, Controller, ControllerRenderProps, FieldValues, Path } from 'react-hook-form';
import { ErrorMessage } from './ErrorMessage';
interface FormFieldProps<T extends FieldValues> {
name: Path<T>;
control: Control<T>;
label?: string;
render: (field: ControllerRenderProps<T>) => ReactElement;
error?: string;
}
export const FormField = <T extends FieldValues>({ name, control, label, render, error }: FormFieldProps<T>) => {
return (
<div className='relative flex flex-col gap-2'>
<label htmlFor={label} className='cursor-pointer text-body2 web:text-heading3'>
{label}
</label>
<Controller name={name} control={control} render={({ field }) => render(field)} />
{error && <ErrorMessage message={error} />}
</div>
);
};
React Hook Form과 Zod를 통합해 Form을 작성한다.
import { FormField } from "@/shared";
import { zodResolver } from "@hookform/resolvers/zod";
import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";
// 작성한 스키마로 타입 추론
type FormFields = z.infer<typeof RegisterSchema>;
export const Form = () => {
const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormFields>({
defaultValue: {
productName: '',
description: '',
minPrice: '',
},
// zodResolver로 연결
resolver: zodResolver(RegisterSchema),
});
const onSubmit: SubmitHandler<FormFields> = async (data) => {
// submit 로직
};
return (
<Layout.Main>
<form onSubmit={handleSubmit(onSubmit)}>
<FormField
label='제목*'
name='productName'
control={control}
error={errors.productName?.message}
render={(field) => <Input id='제목*' type='text' placeholder='제목을 입력해주세요.' {...field} />}
/>
<FormField
label='시작 가격*'
name='minPrice'
control={control}
error={errors.minPrice?.message}
render={(field) => (
<Input
id='시작 가격*'
type='number',
placeholder='최소 시작가는 1,000원입니다.'
{...field}
/>
)}
/>
<FormField
label='상품 설명'
name='description'
control={control}
error={errors.description?.message}
render={(field) => (
<Textarea
id='상품 설명'
placeholder='경매에 올릴 상품에 대해 자세히 설명해주세요.(최대 1,000자)'
{...field}
/>
)}
/>
</form>
</Layout.Main>
);
}
매우 깔끔하다.
react hook form 공식문서
zod 공식문서
React Hook Form - Complete Tutorial (with Zod)
[번역]스키마 유효성 검사 라이브러리 비교: Zod vs. Yup
React: 제어 컴포넌트와 비제어 컴포넌트의 차이점
React Hook Form: Schema validation using Zod