React Hook Form: Zod를 이용한 스키마 검증

React Hook Form: Zod를 이용한 스키마 검증

원문: Bicky Tamang, "React Hook Form: Schema validation using Zod"

React hook form은 리액트의 폼(form)에 사용할 수 있는 라이브러리입니다. 유연하고, 성능이 우수하며, 손쉽게 사용할 수 있습니다. 반면 Zod는 타입스크립트 우선 스키마 선언 및 유효성 검증 라이브러리입니다. '스키마'라는 용어는 간단한 문자열부터 복잡하게 중첩된 객체까지 모든 데이터 타입을 넓게 지칭합니다.

지금까지 스키마 검증에 대해 말해왔지만 사실 그게 정확하게 무엇일까요? 스키마 검증은 데이터 구조가 특정한 스키마를 올바르게 만족하는지를 확인하는 것과 연관이 있습니다. 이는 여러분의 앱의 정적 문서를 제공하기도 하고 사용자 입력의 유효성을 보장해주기도 합니다.

이 글에서는 React hook form과 Zod를 유효성 라이브러리로 사용하여 간단한 등록폼을 만들어 볼 것입니다. 또한 타입스크립트와 기본적이지만 스타일링을 위해 TailwindCSS를 사용할 것입니다. 아래가 바로 우리가 만들어 볼 것입니다.

설치

Create React App 명령어를 사용해 타입스크립트 프로젝트를 만들어볼 것입니다. 아래의 명령어 중 어떤 것이든 선택하여 사용하면 됩니다.

Npx create-react-app react-hook-form-with-zod –template typescript
// 또는
Yarn create react-app react-hook-form-with-zod –template typescript

그리고 TailwindCSS를 설치해 볼 것입니다. TailwindCSS의 설치 과정은 Creat React App으로 Tailwind CSS 설치하기의 기본 가이드를 따릅니다. 링크를 클릭하셔서 상세 지침을 따라 설치하고 다시 돌아와 주세요.

다음으로는 React hook form, Zod 그리고 @hookform/resolvers를 설치해 줄 것입니다. @hookform/resolverZod와 같은 외부 유효성 검사 라이브러리를 사용할 수 있도록 해줍니다.

Npm install react-hook-form @hookform/resolvers zod

이제 끝입니다! 프로젝트에 필요한 의존성을 모두 설치했습니다.

일단 필요하지 않은 파일과 코드부터 삭제하겠습니다. src 폴더 안의 App.css, App.test.tsx, logo.svg, reportWebVitalas.ts 그리고 setupTests.ts를 삭제합니다. App.tsxindex.tsx에서 지운 파일의 imports 코드도 함께 삭제해 주어야 합니다. src 폴더 안에 Form.tsx 파일을 생성하고 컴포넌트를 위한 보일러 플레이트 코드를 추가해 줍니다.

import React from "react";

const Form = () => {
  return (
    <form className="px-8 pt-6 pb-8 mb-4">
      <div className="mb-4 md:flex md:justify-between">
        <div className="mb-4 md:mr-2 md:mb-0">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="firstName"
          >
            First Name
          </label>
          <input
            className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
            id="firstName"
            type="text"
            placeholder="First Name"
          />
        </div>
        <div className="md:ml-2">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="lastName"
          >
            Last Name
          </label>
          <input
            className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
            id="lastName"
            type="text"
            placeholder="Last Name"
          />
        </div>
      </div>
      <div className="mb-4">
        <label
          className="block mb-2 text-sm font-bold text-gray-700"
          htmlFor="email"
        >
          Email
        </label>
        <input
          className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
          id="email"
          type="email"
          placeholder="Email"
        />
      </div>
      <div className="mb-4 md:flex md:justify-between">
        <div className="mb-4 md:mr-2 md:mb-0">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="password"
          >
            Password
          </label>
          <input
            className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
            id="password"
            type="password"
          />
        </div>
        <div className="md:ml-2">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="c_password"
          >
            Confirm Password
          </label>
          <input
            className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
            id="c_password"
            type="password"
          />
        </div>
      </div>
      <div className="mb-4">
        <input type="checkbox" id="terms" />
        <label
          htmlFor="terms"
          className="ml-2 mb-2 text-sm font-bold text-gray-700"
        >
          Accept Terms & Conditions
        </label>
      </div>
      <div className="mb-6 text-center">
        <button
          className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded-full hover:bg-blue-700 focus:outline-none focus:shadow-outline"
          type="submit"
        >
          Register Account
        </button>
      </div>
      <hr className="mb-6 border-t" />
      <div className="text-center">
        <a
          className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
          href="#"
        >
          Forgot Password?
        </a>
      </div>
      <div className="text-center">
        <a
          className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
          href="#"
        >
          Already have an account? Login!
        </a>
      </div>
    </form>
  );
};

export default Form;

아직 아무런 기능을 더하지 않은 폼의 레이아웃과 스타일링을 위한 코드입니다. 이제 App.tsx 안의 코드를 아래의 코드로 교체해 봅시다.

import React from "react";
import Form from "./Form";

function App() {
  return (
    <div className="max-w-xl mx-auto w-full">
      <div className="flex justify-center my-12">
        <div className="w-full lg:w-11/12 bg-white p-5 rounded-lg shadow-xl">
          <h3 className="pt-4 text-2xl text-center font-bold">
            Create New Account
          </h3>
          <Form />
        </div>
      </div>
    </div>
  );
}

export default App;

모든 파일을 저장하고 터미널에서 npm run start를 실행하세요. 브라우저가 열리지 않으면 직접 http://locahost:3000에 접속해 보시면, 짜잔.

Zod를 사용해서 폼 유효성 검사 스키마를 구축하기

우선 Zod에 대해 빠르게 알아봅시다. Zod는 타입스크립트 우선의 스키마 선언이자 유효성 검사 라이브러리입니다. 타입스크립트 우선이라는 뜻은 Zod가 데이터 구조에 대한 정적 타입스크립트 타입을 자동으로 추론하고 유효성 검사기(validator)를 한 번만 선언할 것이라는 뜻입니다. Zod와 타입스크립트에 두 번씩 중복으로 타입을 선언하지 않아도 됩니다. z.infer<typeof schema>를 사용하면 해당 스키마의 타입을 추출할 수 있습니다. 타입스크립트 사용자에게는 정말 멋진 기능이죠.

import { zod } from "Zod";
const personSchema = z.object({
    name: z.string(),
    age: z.number()
});
// extracting the type
type Person = z.infer<typeof personSchema>;

Zod는 번들 사이즈를 작게 유지하도록 도와주는 의존성이 없는 독립적 라이브러리입니다. 간결하고 체이닝 가능한 API들이 있습니다. 복잡한 데이터를 쉽게 만들도록 도와주죠. 다소 단순한 타입을 복잡한 데이터 구조로 구성하는 것은 쉽습니다.

프로젝트로 돌아가 봅시다. 우리는 폼의 값과 일치하는 스키마를 생성할 것입니다. 우선 src/Form.tsx 내의 필요한 의존성을 가져오는 것으로부터 시작해 보죠.

import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

그리고 src/Form.tsx의 import 구문과 폼 컴포넌트 사이에서 커스텀 에러 메시지를 사용하여 validationSchema를 정의해 보겠습니다.

const validationSchema = z
  .object({
    firstName: z.string().min(1, { message: "Firstname is required" }),
    lastName: z.string().min(1, { message: "Lastname is required" }),
    email: z.string().min(1, { message: "Email is required" }).email({
      message: "Must be a valid email",
    }),
    password: z
      .string()
      .min(6, { message: "Password must be atleast 6 characters" }),
    confirmPassword: z
      .string()
      .min(1, { message: "Confirm Password is required" }),
    terms: z.literal(true, {
      errorMap: () => ({ message: "You must accept Terms and Conditions" }),
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    path: ["confirmPassword"],
    message: "Password don't match",
  });

코드를 하나하나 단계별로 살펴보겠습니다.

우리는 z.object() 메서드를 사용하여 validationSchema를 정의할 것입니다. firstnamelastname 필드에 최소 입력 검증을 더한 문자열 검증을 사용하겠습니다. z.string().min(1, { message: 'Firstname is required'})는 값이 문자열인지를 첫 번째로 검사한 후 한 글자 이상인지, 필드가 required 상태일 경우 빈 문자열인지를 검증합니다. 검증 메서드를 사용할 때 추가 인수를 전달해 커스텀 에러 메시지를 제공할 수 있습니다. 추후에 어떻게 에러 메시지를 보여줄 수 있는지 배워보겠습니다.

email 필드에는 이메일 유효성 검사와 함께 문자열 유효성 검사, 최소 길이 유효성 검사를 사용합니다. Zod는 몇 가지 문자열 특화 유효성 검사를 제공하는데 이메일은 그중 하나입니다.

password 필드에선 문자열 검증을 우선적으로 실행한 후에 6자리 이상의 문자가 입력되었는지를 검증할 것입니다. confirmPassword 필드는 firstname, lastname과 동일한 검증을 실행합니다. 또한 passwordconfirmPassword가 일치하는지 검증할 것입니다. Zod를 사용하면 refinements (예: refine)를 통해 커스텀 검증 로직을 실행할 수 있습니다.

.refine((data) => data.password === data.confirmPassword, {
  path: ["confirmPassword"], // path of error
  message: "Password don't match",
});

refine은 외부 객체에 구현된 커스텀 검증이라는 것을 명심하세요. refinepasswordconfirmPassword가 일치하는지를 확인하기 위해 사용됩니다. 보시다시피 refine은 두 가지 인수를 사용합니다. 1) 첫 번째는 검증 함수이며 truthy 한 값은 검증을 통과할 것입니다. 2) 두 번째 인수는 몇몇 옵션을 허용합니다. 에러 경로는 confirmPassword이어야 하는데 path 파라미터를 사용하면 이 에러 경로를 맞출 수 있습니다. message 파라미터는 커스텀 에러 메시지를 제공합니다.

서비스 약관(terms) 체크 박스의 경우 값이 true인 리터럴 유효성 검사를 사용합니다. 리터럴은 해당 필드는 반드시 그 값이어야 함을 뜻합니다. 리터럴의 경우 리터럴 메서드에 파라미터로 전달되는 errorMap을 사용합니다. errorMap은 에러 메시지를 커스텀하는데 쓸 수 있습니다.

그렇다면 validationSchema 스키마로부터 추론되는 타입을 추출하겠습니다.

type ValidationSchema = z.infer<typeof validationSchema>;

타입스크립트가 다음의 타입을 추론해 낸 것을 알 수 있습니다.

type ValidationSchema = {
    firstName: string;
    lastName: string;
    email: string;
    password: string;
    confirmPassword: string;
    terms: true;
}

이는 함수가 타입을 안전하게 유지하도록 합니다. Form.tsx 컴포넌트에서 지금까지 한 것을 요약하자면 아래와 같습니다.

import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const validationSchema = z
  .object({
    firstName: z.string().min(1, { message: "Firstname is required" }),
    lastName: z.string().min(1, { message: "Lastname is required" }),
    email: z.string().min(1, { message: "Email is required" }).email({
      message: "Must be a valid email",
    }),
    password: z
      .string()
      .min(6, { message: "Password must be atleast 6 characters" }),
    confirmPassword: z
      .string()
      .min(1, { message: "Confirm Password is required" }),
    terms: z.literal(true, {
      errorMap: () => ({ message: "You must accept Terms and Conditions" }),
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    path: ["confirmPassword"],
    message: "Password don't match",
  });

type ValidationSchema = z.infer<typeof validationSchema>;

const Form = () => {
  return (
    <form className="px-8 pt-6 pb-8 mb-4">
      <div className="mb-4 md:flex md:justify-between">
        <div className="mb-4 md:mr-2 md:mb-0">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="firstName"
          >
            First Name
          </label>
          <input
            className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
            id="firstName"
            type="text"
            placeholder="First Name"
          />
        </div>
        <div className="md:ml-2">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="lastName"
          >
            Last Name
          </label>
          <input
            className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
            id="lastName"
            type="text"
            placeholder="Last Name"
          />
        </div>
      </div>
      <div className="mb-4">
        <label
          className="block mb-2 text-sm font-bold text-gray-700"
          htmlFor="email"
        >
          Email
        </label>
        <input
          className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
          id="email"
          type="email"
          placeholder="Email"
        />
      </div>
      <div className="mb-4 md:flex md:justify-between">
        <div className="mb-4 md:mr-2 md:mb-0">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="password"
          >
            Password
          </label>
          <input
            className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
            id="password"
            type="password"
          />
        </div>
        <div className="md:ml-2">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="c_password"
          >
            Confirm Password
          </label>
          <input
            className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
            id="c_password"
            type="password"
          />
        </div>
      </div>
      <div className="mb-4">
        <input type="checkbox" id="terms" />
        <label
          htmlFor="terms"
          className="ml-2 mb-2 text-sm font-bold text-gray-700"
        >
          Accept Terms & Conditions
        </label>
      </div>
      <div className="mb-6 text-center">
        <button
          className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded-full hover:bg-blue-700 focus:outline-none focus:shadow-outline"
          type="submit"
        >
          Register Account
        </button>
      </div>
      <hr className="mb-6 border-t" />
      <div className="text-center">
        <a
          className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
          href="#"
        >
          Forgot Password?
        </a>
      </div>
      <div className="text-center">
        <a
          className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
          href="#"
        >
          Already have an account? Login!
        </a>
      </div>
    </form>
  );
};

export default Form;

React hook form을 사용하기

우리는 이미 React hook form에 필요한 의존성들을 추가했습니다.

import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

Form.tsx 컴포넌트에 아래의 코드를 추가할 것입니다.

const Form = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ValidationSchema>({
    resolver: zodResolver(validationSchema),
  });

  const onSubmit: SubmitHandler<ValidationSchema> = (data) => console.log(data);

useForm은 폼을 쉽게 관리해 주는 커스텀 훅입니다. 하나의 객체를 추가적인 인수로 사용합니다. Zod와 같은 외부 검증 라이브러리를 허용해 주는 resolver 프로퍼티를 사용할 것입니다. resolver 메서드에서는 @hookform/resolvers/zod에서 가져온 zodResolver 훅을 사용할 것입니다. 이 훅은 validationSchema 검증을 가져올 수 있습니다. useFormSubmitHandler에 Zod가 추론한 타입을 명시해 주었다는 것을 알아두세요.

간단한 검증 데이터 로그를 콘솔에 찍을 수 있는 onSubmit을 생성했습니다. onSubmit 함수는 handleSubmit 함수가 입력들을 모두 검증한 후에 한 번만 실행될 것입니다. 아직 handleSubmit을 사용해보지 않았다고 걱정할 필요가 없습니다. 지금까지 우리는 useForm 훅의 register, formState와 함께 알아보았습니다.

폼을 채우고 제출하려 할 때 아무 일도 일어나지 않는 것을 확인하세요. 이건 우리가 input을 등록하지 않았으며 제출 이벤트를 처리하기 위해 onSubmit 함수를 사용하지 않았기 때문입니다.

입력(inputs)을 등록하기

React Hook Form의 중요한 개념 중 하나는 컴포넌트를 훅에 등록하는 register입니다. 이렇게 하면 폼의 유효성 검사와 제출을 모두 가능하게 할 수 있습니다.

주의: 각각의 필드는 등록 과정에서 키 값으로 사용될 name이 필요합니다.

useForm 훅에서 반환된 register 함수를 사용하여 form의 input을 등록할 것입니다. validationSchema 스키마에 제공한 같은 이름을 register 함수에 전달하면 됩니다.

firstname 필드는 아래와 같이 등록할 수 있습니다.

<input 
  className="w-full px-3 py-2 text-sm leading-tight text-gray-700        border rounded appearance-none focus:outline-none focus:shadow-  outline"            
  id="firstName"            
  type="text"            
  placeholder="First Name"  
  {...register("fitstName")}        
/>

email 필드도 동일하게 아래와 같이 작성합니다.

<input          
  className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"          
  id="email"          
  type="email"          
  placeholder="Email"
  {...register("email")}       
/>

다른 입력 필드도 같은 방식으로 등록하면 되겠다는 생각이 들 것입니다. 다만 name은 반드시 스키마에 제공한 것과 같아야 함을 명심하세요.

폼 제출을 처리하기

우리는 커스텀 onSubmit 핸들러 함수를 이미 추가했습니다. 남은 일은 handleSubmit 함수에 onSubmit을 인수로 전달하고 처리되도록 하는 것뿐입니다.

<form className="px-8 pt-6 pb-8 mb-4" onSubmit= {handleSubmit(onSubmit)}>

useForm으로부터 handleSubmit 메서드를 가져왔습니다. handleSubmit은 커스텀 onSubmit 메서드가 실행되기 전, 입력을 검증할 것입니다. 기본 이벤트 동작들을 막는 것과 같은 작업은 handleSubmit이 모두 처리하므로 직접 처리하지 않아도 됩니다.

이제 유효한 입력으로 폼을 채우고 제출하면 값이 콘솔에 성공적으로 찍히게 될 것입니다. 그러나 유효하지 않은 입력 또는 비어있는 폼을 제출한다면 아무 일도 일어나지 않을 것입니다. 각 필드가 유효하지 않은 경우 조건에 따라 오류 메시지를 보여줄 것입니다.

에러 처리하기

React Hook Form은 폼의 에러를 보여주는 errors 객체를 제공합니다. errors 타입은 지정된 유효성 검사 제약을 반환합니다. formStateerror 객체로 구조 분해할 수 있습니다.

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<ValidationSchema>({
  resolver: zodResolver(validationSchema),
});

이제 조건에 따라 각각 필드의 error 객체를 통해 에러 메시지를 보여줄 수 있습니다. firstName 필드는 아래와 같습니다.

<input
  className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
  id="firstName"
  type="text"
  {...register("firstName")}
/>
{errors.firstName && (
  <p className="text-xs italic text-red-500 mt-2"> {errors.firstName?.message}
  </p>
)}

나머지 필드는 아래에서 비교해 볼 수 있습니다.

<form className="px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit(onSubmit)}>
      <div className="mb-4 md:flex md:justify-between">
        <div className="mb-4 md:mr-2 md:mb-0">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="firstName"
          >
            First Name
          </label>
          <input
            className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
              errors.firstName && "border-red-500"
            } rounded appearance-none focus:outline-none focus:shadow-outline`}
            id="firstName"
            type="text"
            placeholder="First Name"
            {...register("firstName")}
          />
          {errors.firstName && (
            <p className="text-xs italic text-red-500 mt-2">
              {errors.firstName?.message}
            </p>
          )}
        </div>
        <div className="md:ml-2">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="lastName"
          >
            Last Name
          </label>
          <input
            className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
              errors.lastName && "border-red-500"
            } rounded appearance-none focus:outline-none focus:shadow-outline`}
            id="lastName"
            type="text"
            placeholder="Last Name"
            {...register("lastName")}
          />
          {errors.lastName && (
            <p className="text-xs italic text-red-500 mt-2">
              {errors.lastName?.message}
            </p>
          )}
        </div>
      </div>
      <div className="mb-4">
        <label
          className="block mb-2 text-sm font-bold text-gray-700"
          htmlFor="email"
        >
          Email
        </label>
        <input
          className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
            errors.email && "border-red-500"
          } rounded appearance-none focus:outline-none focus:shadow-outline`}
          id="email"
          type="email"
          placeholder="Email"
          {...register("email")}
        />
        {errors.email && (
          <p className="text-xs italic text-red-500 mt-2">
            {errors.email?.message}
          </p>
        )}
      </div>
      <div className="mb-4 md:flex md:justify-between">
        <div className="mb-4 md:mr-2 md:mb-0">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="password"
          >
            Password
          </label>
          <input
            className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
              errors.password && "border-red-500"
            } rounded appearance-none focus:outline-none focus:shadow-outline`}
            id="password"
            type="password"
            {...register("password")}
          />
          {errors.password && (
            <p className="text-xs italic text-red-500 mt-2">
              {errors.password?.message}
            </p>
          )}
        </div>
        <div className="md:ml-2">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="c_password"
          >
            Confirm Password
          </label>
          <input
            className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
              errors.confirmPassword && "border-red-500"
            } rounded appearance-none focus:outline-none focus:shadow-outline`}
            id="c_password"
            type="password"
            {...register("confirmPassword")}
          />
          {errors.confirmPassword && (
            <p className="text-xs italic text-red-500 mt-2">
              {errors.confirmPassword?.message}
            </p>
          )}
        </div>
      </div>
      <div className="mb-4">
        <input type="checkbox" id="terms" {...register("terms")} />
        <label
          htmlFor="terms"
          className={`ml-2 mb-2 text-sm font-bold ${
            errors.terms ? "text-red-500" : "text-gray-700"
          }`}
        >
          Accept Terms & Conditions
        </label>
        {errors.terms && (
          <p className="text-xs italic text-red-500 mt-2">
            {errors.terms?.message}
          </p>
        )}
      </div>
      <div className="mb-6 text-center">
        <button
          className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded-full hover:bg-blue-700 focus:outline-none focus:shadow-outline"
          type="submit"
        >
          Register Account
        </button>
      </div>
      <hr className="mb-6 border-t" />
      <div className="text-center">
        <a
          className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
          href="#test"
        >
          Forgot Password?
        </a>
      </div>
      <div className="text-center">
        <a
          className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
          href="./index.html"
        >
          Already have an account? Login!
        </a>
      </div>
    </form>

저는 유효하지 않은 입력이 들어왔을 때 조건에 따라 빨간 테두리로 하이라이트 되도록 className${ errors.firstName && “border-red-500” }를 추가해 주었습니다. 이제 유효하지 않은 입력을 넣은 후 제출하면 각각의 필드에서 에러 메시지가 보일 것입니다.

지금까지 Form.tsx 파일에서 작업한 것을 요약하면 아래와 같습니다.

import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const validationSchema = z
  .object({
    firstName: z.string().min(1, { message: "Firstname is required" }),
    lastName: z.string().min(1, { message: "Lastname is required" }),
    email: z.string().min(1, { message: "Email is required" }).email({
      message: "Must be a valid email",
    }),
    password: z
      .string()
      .min(6, { message: "Password must be atleast 6 characters" }),
    confirmPassword: z
      .string()
      .min(1, { message: "Confirm Password is required" }),
    terms: z.literal(true, {
      errorMap: () => ({ message: "You must accept Terms and Conditions" }),
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    path: ["confirmPassword"],
    message: "Password don't match",
  });

type ValidationSchema = z.infer<typeof validationSchema>;

const Form = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ValidationSchema>({
    resolver: zodResolver(validationSchema),
  });

  const onSubmit: SubmitHandler<ValidationSchema> = (data) => console.log(data);

  return (
    <form className="px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit(onSubmit)}>
      <div className="mb-4 md:flex md:justify-between">
        <div className="mb-4 md:mr-2 md:mb-0">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="firstName"
          >
            First Name
          </label>
          <input
            className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
              errors.firstName && "border-red-500"
            } rounded appearance-none focus:outline-none focus:shadow-outline`}
            id="firstName"
            type="text"
            placeholder="First Name"
            {...register("firstName")}
          />
          {errors.firstName && (
            <p className="text-xs italic text-red-500 mt-2">
              {errors.firstName?.message}
            </p>
          )}
        </div>
        <div className="md:ml-2">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="lastName"
          >
            Last Name
          </label>
          <input
            className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
              errors.lastName && "border-red-500"
            } rounded appearance-none focus:outline-none focus:shadow-outline`}
            id="lastName"
            type="text"
            placeholder="Last Name"
            {...register("lastName")}
          />
          {errors.lastName && (
            <p className="text-xs italic text-red-500 mt-2">
              {errors.lastName?.message}
            </p>
          )}
        </div>
      </div>
      <div className="mb-4">
        <label
          className="block mb-2 text-sm font-bold text-gray-700"
          htmlFor="email"
        >
          Email
        </label>
        <input
          className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
            errors.email && "border-red-500"
          } rounded appearance-none focus:outline-none focus:shadow-outline`}
          id="email"
          type="email"
          placeholder="Email"
          {...register("email")}
        />
        {errors.email && (
          <p className="text-xs italic text-red-500 mt-2">
            {errors.email?.message}
          </p>
        )}
      </div>
      <div className="mb-4 md:flex md:justify-between">
        <div className="mb-4 md:mr-2 md:mb-0">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="password"
          >
            Password
          </label>
          <input
            className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
              errors.password && "border-red-500"
            } rounded appearance-none focus:outline-none focus:shadow-outline`}
            id="password"
            type="password"
            {...register("password")}
          />
          {errors.password && (
            <p className="text-xs italic text-red-500 mt-2">
              {errors.password?.message}
            </p>
          )}
        </div>
        <div className="md:ml-2">
          <label
            className="block mb-2 text-sm font-bold text-gray-700"
            htmlFor="c_password"
          >
            Confirm Password
          </label>
          <input
            className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
              errors.confirmPassword && "border-red-500"
            } rounded appearance-none focus:outline-none focus:shadow-outline`}
            id="c_password"
            type="password"
            {...register("confirmPassword")}
          />
          {errors.confirmPassword && (
            <p className="text-xs italic text-red-500 mt-2">
              {errors.confirmPassword?.message}
            </p>
          )}
        </div>
      </div>
      <div className="mb-4">
        <input type="checkbox" id="terms" {...register("terms")} />
        <label
          htmlFor="terms"
          className={`ml-2 mb-2 text-sm font-bold ${
            errors.terms ? "text-red-500" : "text-gray-700"
          }`}
        >
          Accept Terms & Conditions
        </label>
        {errors.terms && (
          <p className="text-xs italic text-red-500 mt-2">
            {errors.terms?.message}
          </p>
        )}
      </div>
      <div className="mb-6 text-center">
        <button
          className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded-full hover:bg-blue-700 focus:outline-none focus:shadow-outline"
          type="submit"
        >
          Register Account
        </button>
      </div>
      <hr className="mb-6 border-t" />
      <div className="text-center">
        <a
          className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
          href="#test"
        >
          Forgot Password?
        </a>
      </div>
      <div className="text-center">
        <a
          className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
          href="./index.html"
        >
          Already have an account? Login!
        </a>
      </div>
    </form>
  );
};

export default Form;

결론

이 글에서는 React hook form의 유효성 검증 라이브러리로 Zod를 어떻게 사용할 수 있는지를 다루었습니다. 또한 어떻게 스키마를 생성하고 폼에서 추론된 타입을 어떻게 사용할 수 있는지도 보았습니다. 두 라이브러리의 기초를 다루고 두 가지를 어떻게 통합할 수 있는지 다루려 노력했습니다.

다음 단계를 위해선 React hook formZod의 공식 문서를 통해 더 많은 고급 개념들을 익혀보세요.

만약 글에서 다룬 코드가 전체적으로 어떻게 보이는지 알고 싶다면 깃허브 리포지토리를 참조하세요.

즐거운 코딩되시길! 다음 글에서 만나요.