React Hook Form과 Zod를 이용한 효율적인 Form 관리

JunSeok·2024년 11월 25일
0

지식 기록

목록 보기
14/14
post-thumbnail

필요성

리액트에서 단순한 form은 쉽게 관리할 수 있지만 대규모 form으로 발전하면 다음과 같은 문제가 발생한다.

  • 관리 복잡성 증가: 입력 필드가 추가될 때마다 새로운 상태와 로직이 추가되어 코드가 방대해진다.
  • 유효성 검사 관리 부담: 유효성 검사 로직이 분산되고 복잡하여 유지보수가 어려워진다.

이를 해결하기 위해 React Hook Form과 Zod를 활용하면 다음과 같은 장점이 있다.

  • 간결한 코드 작성: Hook 기반으로 직관적이고 깔끔한 코드 작성이 가능하다.
  • 유효성 검사 통합: 유효성 검사를 간단히 처리하며, 타입과 데이터 구조 관리를 쉽게 할 수 있다.

React Hook Form의 특징

성능 최적화

  • 비제어 컴포넌트 방식을 기반으로 작동하여 DOM의 참조(ref)를 활용해 불필요한 렌더링을 줄인다.
  • watch 기능으로 실시간 상태 추적이 가능하여 제어 컴포넌트의 장점을 함께 제공한다.

간결한 코드

  • useForm 같은 Hook 기반 API로 직관적이고 간결한 코드 작성이 가능하다.
  • register 를 사용해 필드 값과 유효성 검사를 자동으로 관리한다.

유연한 유효성 검사

  • HTML5 기본 유효성 검사를 지원한다.
  • Zod와 같은 Schema 기반 유효성 검사 라이브러리 호환을 지원한다. 공식 문서
  • 비동기 유효성 검사를 지원한다.

외부 라이브러리와의 호환성

Material-UI 같은 UI 라이브러리나 shadcn/ui 같은 component collection과 쉽게 통합 가능하다.
공식 문서

제어 컴포넌트 vs 비제어 컴포넌트

제어 컴포넌트

리액트 내부에서 값이 제어되는 컴포넌트를 의미하며, 리액트의 상태를 통해 입력 필드의 값을 관리하는 방식이다.

  • 장점: 상태와 UI가 동기화되며 실시간 유효성 검사가 쉽다.
  • 단점: 상태 관리 로직으로 인해 코드가 복잡해지고 리렌더링으로 인해 성능 문제가 발생할 수 있다.

비제어 컴포넌트

DOM이 직접 입력값을 괸리하는 방식으로 useRef를 활용한다.

useRef는 heap 영역에 저장되는 일반적인 자바스크립트 객체로, 애플리케이션이 종료되거나 가비지 컬렉팅될 때까지 참조할 때마다 같은 메모리 값을 가진다. 값이 변경되어도 같은 메모리 주소를 가지고 있기 때문에 리액트는 변경사항을 감지할 수 없어 리렌더링하지 않는다. 이를 통해 렌더링 횟수를 줄이고 성능을 최적화할 수 있다.

  • 장점: 코드가 간단하고 리렌더링이 최소화되어 성능적으로 유리하다.
  • 단점: 상태와 유효성 검사를 직접 관리해야 하므로 복잡해질 수 있고 데이터 흐름이 직관적이지 않다.

Zod: 스키마 기반 유효성 검사 라이브러리

Zod는 타입스크립트를 우선으로 설계된 스키마(Schema) 선언 및 유효성 검사 라이브러리이다.
데이터 스키마를 선언적으로 정의하여 사용하는데, 여기서 스키마란 데이터의 형태, 데이터의 타입 그리고 데이터가 충족해야 할 조건들을 지정한다.
해당 스키마를 기준으로 Zod는 주어진 데이터를 검증하고 검증에 실패하면 에러를 리턴한다. 이를 통해 데이터의 무결성을 유지하고 예상치 못한 데이터 구조로 발생하는 에러를 방지할 수 있다.

특징

  • 스키마 정의: 스키마를 통해 Object, Array, String, Number 등의 타입과 그에 따른 조건을 정의한다.
  • 유효성 검사: parse()safeParse() 메서드로 데이터를 검증한다.
  • 타입 추론: 타입스크립트 타입을 스키마로부터 자동으로 추론한다.
  • 동적 스키마 구성: 함수형 API로 복잡한 조건부 유효성 검사를 처리할 수 있다.

React Hook Form과 Zod 연결

@hookform/resolvers 라이브러리를 사용하여 React Hook Form과 Zod의 스키마를 연결한다.

  • Zod는 타입스크립트 우선 라이브러리이기 때문에 스키마를 통해 타입과 유효성 검사를 동시에 처리할 수 있다.
  • 스키마에서 작성한 에러 메시지를 React Hook Form의 error 객체에서 바로 활용할 수 있다.
  • 동일한 스키마를 여러 form에서 재활용할 수 있어 코드 중복을 줄이고 유지보수가 용이해진다.
  • 입력 필드의 조건부 유효성 검사나 복잡한 데이터 구조 처리하는데 매우 유연하다.

사용 예시

스키마 선언

  • 데이터 구조와 유효성 검사를 정의한다.
  • 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('')),
});

FormField 컴포넌트 구현

재사용가능한 컴포넌트로 분리하여 재사용성을 높인다.

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>
  );
};

Form 컴포넌트 구현

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

profile
최선을 다한다는 것은 할 수 있는 한 가장 핵심을 향한다는 것

0개의 댓글