見出し画像

ZodとReact Hook Formを使用したシンプルかつ安全なフォームの実装例

CyberZのWebフロントチームでエンジニアをしている山﨑です。CyberZでは、新たな技術の導入を積極的に行なっており、その中で zodとReact Hook Form を使用したフォームの実装を行なったため、その実装例をご紹介したいと思います。

zodの基本的な使い方

  • TypeScript First なバリデーション(検証)ライブラリ

  • パースとバリデーションを同時に行なってくれる

  • APIレスポンスの検証を行い、modelの生成も可能

https://github.com/colinhacks/zod
Zodとは、「静的型推論によるTypescript Firstなスキーマ検証ライブラリ」です。もう少し具体的に説明をすると、「Typescriptの型定義と同じ形式で、データ構造の検証を行ってくれるライブラリ」です。以下のようなデータ構造の値が、本当にほしいデータの状態かを検証し、異なっていた場合にエラーを吐いてくれます。

const schema = z.object({
  str: z.string(),
  num: z.number(),
})

const success = schema.parse({ str: 'success', num: 0 });
const error = schema.parse({ str: 'error' }); // { num: number }が足らないよ

また、型の検証だけでなく、検証の条件を追加することができます。例えば、文字列の文字数や数値の以上以下などの条件を追加することができるため、詳細なデータ検証を行うことができます。

z.string().min(1).max(10); // 1文字以上、10文字以下の文字列
z.string().uuid();         // uuid形式の文字列
z.string().email();        // メールアドレス形式の文字列
z.string().url();          // URLの文字列

さらに、zodは「Typescriptの型からSchemaの生成」、「SchemaからTypescriptの型生成」の両方を行うことができます。これにより、型と検証に使うschemaで定義が異なる心配がなくなります。

// Typescript -> Schema
type Test = {
  str: string;
  num: number;
};

const TestSchema: z.ZodType<Test> = z.object({
  str: z.string().min(1),
  num: z.number().min(1),
});

// Schema -> Typesctipt
const schema = z.object({
  str: z.string().min(1),
  num: z.number().min(1),
});

type SchemaType = z.infer<typeof schema>;

React Hook Formの基本的な使い方

  • Formの入力値を簡単にまとめて取得できる

  • バリデーション(検証)も一緒に行ってくれる

https://github.com/react-hook-form/react-hook-form
React Hook Formは、「フォームの操作と検証までを簡潔に行なってくれるライブラリ」です。一般的に、Reactでフォームを作成する場合には、useStateやuseRefを用いて実装を行います。しかし、React Hook Formを使用することで、レンダリング数の減少によるパフォーマンスの向上と、検証による安全性を向上させることができます。
基本的には、React Hook Formで提供されているuseForm APIを利用します。また、簡単なフォームの場合は、useFormの以下3つのメソッド、オブジェクトを利用することで、フォームの実装を行えます。

  • register

このメソッドは、HTMLのinputタグもしくはselectタグに対して使用することができ、その要素が検証のルールに則っているか判定することができます。以下の例では、emailとpasswordが文字列型であるかどうかを判定してくれます。

  • handleSubmit

このメソッドは、registerの検証が成功したときの実行処理を指定することができます。引数に検証したデータの値を持つため、以下の例では、emailとpasswordのオブジェクトを受け取ることができます。

  • formState

フォーム全体の状態を保持しているオブジェクトです。以下の例では、registerに登録した値の検証がエラーだった時の、エラーメッセージの取得・表示しています。

import { useForm } from "react-hook-form";

type User = {
  email: string;
  password: string;
}

export default function App() {
  const {
    register,               // email, password の値がstring型と一致するかを判定
    handleSubmit,           // email, password の値がstring型と一致する時、データをログに表示
    formState: { errors },  // email, password の値がstring型と一致しない時、エラー文言を表示
  } = useForm<User>();

  return (
    <form onSubmit={ handleSubmit((data) => console.log(data)) }>
      <input { ...register("email") } />
      <input { ...register("password") } />
			
      { errors.email && <span>email error</span> }
      { errors.password && <span>password error</span> }
      
      <input type="submit" />
    </form>
  );
}

zod × React Hook Formの使い方と役割

https://github.com/react-hook-form/resolvers
zodとReact Hook Formは、React Hook Form側の公式から提供されている resolver を使用するだけで、簡単に組み合わせることができます。

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

// zodのschemaを作成
const UserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(10),
});

const App = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(UserSchema), // ReactHookForm に resolver として schemaを渡してあげるだけ
  });

  const onSubmit = data => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      <input {...register('password')} />

      { errors.email && <span>email error</span> }
      { errors.password && <span>password error</span> }

      <input type="submit" />
    </form>
  );
};

ですが、React Hook Form側にも検証方法が提供されているため、zodまで使う必要がないのではないかと感じます。確かに、React Hook Formの検証だけでも安全性の高いフォームを作成できます。しかし、zodと併用することで、フォームとしての機能と検証の役割を分けることができ、複雑な検証条件を指定している時のコードの可読性が向上します。

// 🙅‍♂️zodを使用しなかったパターン🙅‍♀️

//...省略...

type User = {
  email: string;
  password: string;
  confirmPassword: string;
}

export default function App() {

  //...省略...

  return (
    <form onSubmit={ handleSubmit((data) => console.log(data)) }>
      <input
        type='email'
        { ...register("email", {
          required: "メールアドレスを入力してください。",
          pattern: {
            value: /^[\\w\\-._]+@[\\w\\-._]+\\.[A-Za-z]+/,
            message: "入力形式がメールアドレスではありません。"
          }
        }) }
      />
            <input
        type='password'
                { ...register("password", {
                   required: "パスワードを入力してください。",
                 }) }
      />
      <input
        type='password'
        { ...register("confirmPassword", {
                    required: "確認用のパスワードを入力してください。",
                    validate: (value) => {
            return (
	      value === getValues("password") || "パスワードが一致しません"
	    );
	  }
        }) }
      />
			
      { errors.email && <span>email error</span> }
      { errors.password && <span>password error</span> }
      { errors.confirmPassword && <span>confirmPassword error</span> }
      
      <input type="submit" />
    </form>
  );
}

このように、メールアドレスの形式やパスワードの一致などの検証を含めると、コードが煩雑になってしまう問題があります。

// 🙆‍♂️zodを使用したパターン🙆‍♀️

//...省略...

const UserSchema = z.object({
  email: z.string({ required_error: 'メールアドレスを入力してください。' })
                     .email({ message: '入力形式がメールアドレスではありません。'}),
  password: z.string({ required_error: 'パスワードを入力してください。' }),
  confirmPassword: z.string({ required_error: '確認用パスワードを入力してください。' }),
})
  .refine((value) => value.password === value.confirmationPassword, {
    message: 'パスワードが一致しません',
  });

const App = () => {

  //...省略...

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type='email' { ...register('email') } />
      <input type='password' { ...register('password') } />
      <input type='password' { ...register('confirmPassword') } />

      { errors.email && <span>email error</span> }
      { errors.password && <span>password error</span> }
      { errors.confirmPassword && <span>confirmPassword error</span> }

      <input type="submit" />
    </form>
  );
};

このように、検証内容を全てSchemaに収めることができるため、複雑な検証であってもスッキリ書くことができます。


こういう使い方をしています

  • ログイン・ユーザー登録に対するhookを作ってあげる

そして、zodとReact Hook Formをhookで合わせてあげることで、より使い勝手が良くなります。フォームを作成するために必要なReact Hook Formの値と、その値の検証に必要なzodのschemaを、全てhook内で生成してあげています。そうすることで、view側は最低限の値だけで、安全性の高いフォームを作成することができます。

//// hook.ts

import { useCallback } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { z } from 'zod';

// ユーザー登録の際に必要なデータ
type SignupData = {
  userId: string;
  email: string;
  password: string;
  confirmationPassword: string;
};

// typescriptの型を元に、zodのschemaを作成
const SignupSchema: z.ZodType<SignupData> = z.object({
  userId: z.string().min(1, 'ユーザーIDを入力してください'),
  email: z.string().email('メールアドレスを入力してください'),
  password: z.string().min(1, 'パスワードを入力してください'),
  confirmationPassword: z.string().min(1, '確認用パスワードを入力してください'),
})
  .refine((value) => value.password === value.confirmationPassword, {
    message: 'パスワードが一致しません',
  });

// ユーザー登録のデータを返すhook
const useSignup = () => {
  // zod × React Hook Form を合わせて使用する
  const {
    register,
    handleSubmit,
    format: { errors },
  } = useForm<SignupData>({ resolver: zodResolver(SignupSchema) });

  const onSubmit: SubmitHandler<SignupData> = useCallback((data) => {
    // フォームクリック時の処理
  }, []);

  return {
    register,
    handlerSubmit,
    onSubmit,
    errors,
  };
};

export default useSignup;

まとめ

  • zodとReact Hook Formのかけあわせで、安全性の高いフォームを簡単に作成できます

  • React Hook Formだけでなく、検証にzodを用いることで、役割の分離とコードの可読性を向上させることができます

  • hookとして切り出してあげることで、view側に与える影響も最小限で済みます

今回の記事では、zodとReact Hook Formのかけ合わせの使用例を紹介しました。今回の調査を通して、zodとReact Hook Formのかけ合わせで簡単かつ安全なフォームの実装が行えることが学べたとともに、検証と役割の分離の大切さを再認識することができました。しかし、今回の記事だけでは紹介しきれなかった部分もあるため、それらについては第二弾としてご紹介できればと思います。

この記事が気に入ったらサポートをしてみませんか?