abeshi blog
カテゴリーで検索

react hook formを使ってInputFieldのコンポーネントを作成しよう!

2021年12月6日

フロントでフォームのバリデーションをかける。

これはフロントエンドを触っていれば必ず発生する局面です。
今回はReactとTypeScriptを用いいて優秀なreact hook formのライブラリーを使って、使いやすいコンポーネントを設計していこうと思います!!

完成イメージ












今回のテーマ


今回はテーマとして会員登録のフォームを作成していきます。
わかりやすいようにemailとpasswordを入力して会員登録をする想定にします。

React, TypeScript, react hook form 7.20.5
で行っていきます。

react hook formとは



react hook form とはTypeScript製のフォームをにに関するバリデーション等あらゆるものが入っているライブラリーです。
大体Reactでフォームを作る際はこちらが使用されることが多い気がします。

基本的な使い方は下記公式ドキュメントを参照ください。
https://react-hook-form.com/ts

npm install react-hook-form


// 公式ドキュメントより引用

import React from "react";
import { useForm, SubmitHandler } from "react-hook-form";   // ライブラリーを読み込む

type FormValues = { 
  firstName: string;
  lastName: string;
  email: string;
};  // フォームに入力するvalueの型定義をしている

export default function App() {
  const { register, handleSubmit } = useForm<FormValues>();  // ジェネリックスで型を指定します。registerにvalueで登録する値が入ります。
  const onSubmit: SubmitHandler<FormValues> = data => console.log(data);  // SubmitHandlerでdataにFormValuesの値が入ってきます。

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("firstName")} /> // FormValuesで定義したfirstNameを取得している
      <input {...register("lastName")} />  // FormValuesで定義したlastNameを取得している
      <input type="email" {...register("email")} />     // FormValuesで定義したemailを取得している

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



詳しく知りたい方はこれから説明していきますが、まずは公式ドキュメントを読んでみましょう!

InputFieldコンポーネントの設計を考える


まずはinputFieldに最低限必要なものを考えていきます。

label, placeholder, typeまずこの辺りは必須で必要かと思います!
こちらはpropsで渡していくことにします。

あとはバリデーション周りです。
必須か?最小文字数, 最大文字数, 正規表現

この辺りは必須のバリデーションになるかと思うので、今回は作成していきたいと思います!

上記を踏まえ、今回のpropsの型定義は下記のようにしていきます。

type InputFiledType = "text" | "password" | "email";

type Props = {
  label?: string;
  placeholder?: string;
  type: InputFiledType;
  required: boolean;
  guidance?: string;
  disabled?: boolean;
  name: string;
  maxLength?: number;
  minLength?: number;
  defaultValue?: string;
  parttern?: ValidationRule<RegExp>;
}



react hook formではregisterに渡す値がnameで定義されている為、propsの名前も合わせてnameにしました。


InputFieldコンポーネントを作成していく



src/components/atoms/InputField/InputField.module.scssも作成します。
sassを導入しているので、installしたい方は

npm install --save sass


// src/components/atoms/InputField/InputField.tsx


// - フレームワーク, ライブラリー ===========================================================================================
import React, { memo, VFC } from "react";
import { useFormContext, ValidationRule } from "react-hook-form";

// - アセット ============================================================================================================
import styles from"./InputField.module.scss";

type InputFiledType = "text" | "password" | "email";

type Props = {
  label?: string;
  placeholder?: string;
  type: InputFiledType;
  required: boolean;
  guidance?: string;
  disabled?: boolean;
  name: string;
  maxLength?: number;
  minLength?: number;
  defaultValue?: string;
  pattern?: ValidationRule<RegExp>;
} // propsの型定義

/* eslint-disable-next-line react/display-name */
export const InputField: VFC<Props> = memo((props) => {

  const {
    label,
    placeholder,
    type,
    required,
    guidance,
    disabled = false,
    name,
    maxLength,
    minLength,
    defaultValue,
    pattern
  } = props; 

  const { register } = useFormContext();   // 子コンポーネント側ではuseFormContextを使います。

  return (
    <div className={styles.inputFieldContainer}>
      <div className={styles.labelAndRequiredBadge}>
        {label && <label htmlFor={label} className={styles.label}>{label}</label>}  //  labelがあればlabelを表示
        {required && <span className={styles.requiredBadge}>必須</span>}  // 必須であれば必須のバッジを表示するようにしています
      </div>
      <input
        className={styles.inputField}
        defaultValue={defaultValue}  // defaultValueを設定できます。編集などの際はこちらにpropsを渡すことになります
        type={type}   //  今回はtextpasswordemailです。
        placeholder={placeholder}
        disabled={disabled}
        {...register(`${name}`, {  // ここでregisternameを渡しています。
          required: required,  // ここからバリデーション
          maxLength: maxLength,
          minLength: minLength,
          pattern: pattern
        })}
      />
      {guidance && <p className={styles.guidance}>{guidance}</p>}  // 今回はguidanceをpropsで受け取って補足の文章がある場合は表示するようにしています
    </div>
  );
});



コメントで追記していますが、...register(${name})の部分でinputのvalueを受け取っています。
こちらを後ほど親コンポーネントからemailとpasswordを渡していきます。


スタイルもコピペで整えておきましょう。

// src/components/atoms/InputField/InputField.module.scss

.inputFieldContainer {

  display: flex;
  flex-direction: column;
}


.labelAndRequiredBadge {

  display: flex;
  align-items: center;
}

.label {

  font-size: 14px;
}


.requiredBadge {

  font-size: 12px;
  color: #C65151;
  background: #ffe1e1;
  border-radius: 5px;

  margin-left: 10px;

  padding: 3px 5px;
}


.inputField {

  height: 45px;

  background: #EDEDED;

  border-style: solid;
  border-color: #EDEDED;
  border-width: 1px;
  border-radius: 5px;

  margin-top: 10px;


  &::placeholder {

    color: #ABB1BA;
  }


  &:hover {

    opacity: 80%;
  }


  &:focus {

    border-color: #7B828E;
    box-shadow: 0 0 6px rgba(black, 0.16);
  }


  &:active {

    border-color: #7B828E;
    box-shadow: none;
  }


  &:disabled {

    opacity: 25%;
  }

}


.guidance {

  font-size: 12px;
  color: #5F5F5F;
}



親コンポーネントを作成する(SignUp)



今回は
親コンポーネント: SignUp
子コンポーネント; SignUpControlGroup
孫コンポーネント: InputField
といった構成にしていきます。


こちらはコピペしてください。

// src/components/pages/SignUp/SignUp.tsx

// - フレームワーク =======================================================================================================
import React, { memo, VFC} from "react";

// - アセット ===========================================================================================================
import styles from "./SignUp.module.scss";

// - 子コンポーネント =====================================================================================================
import { SignUpControlGroup } from "../../organism/controlGroup/SignUpControlGroup/SignUpControlGroup";

/* eslint-disable-next-line react/display-name */
export const SignUp: VFC = memo(() => {

  return (
    <div className={styles.signUp}>
      <h1>SignUpページです</h1>
      <SignUpControlGroup/>
    </div>
  );
});



// src/components/pages/SignUp/SignUp.module.scss

.signUp {

  max-width: 800px;
  margin: 0 auto;
}



子コンポーネントを作成する(SignUpControlGroup)


// src/components/organism/controlGroup/SignUpControlGroup/SignUpControlGroup.tsx

// - ライブラリー ========================================================================================================
import React, { memo, VFC } from "react";
import { createUserWithEmailAndPassword } from "firebase/auth";
import { auth } from "../../../../firebase";
import { SubmitHandler, useForm, FormProvider, FieldError } from "react-hook-form";

// - アセット ===========================================================================================================
import styles from "./SignUpControlGroup.module.scss";

// - 子コンポーネント =====================================================================================================
import { InputField } from "../../../atoms/InputField/InputField";

// - バリデーション =======================================================================================================
import { signUpValidations } from "../../../../config/validations/signUpValidations";


// - inputState ========================================================================================================
export type SignUpInputValues = {
  email: string,
  password: string,
};

const signUpInputValue: SignUpInputValues = {
  email: "email",
  password: "password"
}
// - ===================================================================================================================


// - エラーメッセージ =====================================================================================================
const emailErrorMessages = (error: FieldError) => {
  switch (error.type) {
    case "required": return <span className="errorMessage">メールアドレスは必須です</span>;
    case "pattern": return <span className="errorMessage">不正なメールアドレスです。(正しい例: example@example.com)</span>;
  }
}

const passwordErrorMessages = (error: FieldError) => {
  switch (error.type) {

    case "required": return <span className="errorMessage">パスワードは必須です</span>;

    case "minLength": return <span className="errorMessage">
      {`パスワードは${signUpValidations.password.minLength}〜${signUpValidations.password.maxLength}文字で入力してください`}
    </span>;

    case "maxLength": return <span className="errorMessage">
      {`パスワードは${signUpValidations.password.minLength}〜${signUpValidations.password.maxLength}文字で入力してください`}
    </span>
  }
}
// - ===================================================================================================================


/* eslint-disable-next-line react/display-name */
export const SignUpControlGroup: VFC = memo(() => {

  const methods = useForm<SignUpInputValues>();

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

  return (
    <FormProvider { ...methods }>
      <form className={styles.signInControlGroup} onSubmit={methods.handleSubmit(onSubmit)}>

        <div className={styles.inputContainer}>
          <InputField
            type="email"
            required={signUpValidations.email.required}
            name={signUpInputValue.email}
            label="メールアドレス"
            placeholder="メールアドレスを入力してください"
            pattern={signUpValidations.email.regexp}
          />
          {methods.formState.errors.email && emailErrorMessages(methods.formState.errors.email)}
        </div>

        <div className={styles.inputContainer}>
          <InputField
            type="password"
            required={signUpValidations.password.required}
            name={signUpInputValue.password}
            minLength={signUpValidations.password.minLength}
            maxLength={signUpValidations.password.maxLength}
            label="パスワード"
            guidance="※パスワードは最低6文字以上で入力してください"
            placeholder="パスワードを入力してください"
          />
          {methods.formState.errors.password && passwordErrorMessages(methods.formState.errors.password)}
        </div>

        <button type="submit">送信</button>
      </form>
    </FormProvider>
  );
});




説明していきます。
子コンポーネントにregisterを渡したい場合は、FormProviderを定義して

const methods = useForm<SignUpInputValues>();



で定義したmethodsを渡す必要があります。
ジェネリックスでSignUpInputValuesを型定義しているので、

<FormProvider { ...methods }>


export type SignUpInputValues = {
  email: string,
  password: string,
};



今回はemailとpasswordをregisterに渡すことになります。

下記がInputFieldを呼び出している部分になります。

<InputField
  type="email"
  required={signUpValidations.email.required} 
  name={signUpInputValue.email}  // ここだけ型指定ができていない、、、
  label="メールアドレス"
  placeholder="メールアドレスを入力してください"
  pattern={signUpValidations.email.regexp}
/>



nameの部分だけ安全な型指定がInputFieldでできておらず、親コンポーネント側から

// - inputState ========================================================================================================
export type SignUpInputValues = {
  email: string,
  password: string,
};

const signUpInputValue: SignUpInputValues = {
  email: "email",
  password: "password"
}
// - ===================================================================================================================



で明示的に渡しています。


エラーハンドリング



次にバリデーションのエラーハンドリングです。

{methods.formState.errors.email && emailErrorMessages(methods.formState.errors.email)}


メールアドレスの場合このように渡してあります。
エラーがある場合のみエラーメッセージを表示するようにしています。
エラーメッセージは冗長にならないようgetterで定義してます。

const emailErrorMessages = (error: FieldError) => {
  switch (error.type) {
    case "required": return <span className="errorMessage">メールアドレスは必須です</span>;
    case "pattern": return <span className="errorMessage">不正なメールアドレスです。(正しい例: example@example.com)</span>;
  }
}



errorの型定義はライブラリーが用意してくれているFieldErrorで定義することができます。
こちらでerror.typeでスイッチ文で返すエラーメッセージを分岐しています。

エラーメッセージのスタイルはグローバルに定義しています。
こちらをindex.tsxでimportしましょう

// src/assets/global/errorMessage.scss

.errorMessage {

  color: #D90000;
  font-size: 12px;

  margin-top: 3px;
}



バリデーションの値をハードコーディングせずにまとめる



最大文字数等のバリデーションの値はハードコーディングしてはいけません。

悪い例

<InputField
  type="password"
  name={signUpInputValue.password}
  minLength={6}  **********
  guidance="※パスワードは最低6文字以上で入力してください"
  placeholder="パスワードを入力してください"
/>

<span className="errorMessage">パスワードは6文字以上で入力してください</span>



この場合だと6という数字がハードコーディングされている為、この値を修正しようとしたときに、いろいろな箇所を修正しなくてはなりません。
漏れが出ると、バグに繋がる可能性もあります。

なので別ファイルに分けてハードコーディングしないようにしましょう。

// src/config/validations/signUpValidations.ts

export const signUpValidations = {

  email: {
    required: true,
    regexp: /^[a-zA-Z0-9_.+-]+@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/
  },

  password: {
    required: true,
    minLength: 6,
    maxLength: 36
  }
};


今回は上記のように定義しました。

<InputField
  type="password"
  required={signUpValidations.password.required}
  name={signUpInputValue.password}
  minLength={signUpValidations.password.minLength}
  maxLength={signUpValidations.password.maxLength}
  label="パスワード"
  guidance="※パスワードは最低6文字以上で入力してください"
  placeholder="パスワードを入力してください"
/>

const passwordErrorMessages = (error: FieldError) => {
  switch (error.type) {

    case "required": return <span className="errorMessage">パスワードは必須です</span>;

    case "minLength": return <span className="errorMessage">
      {`パスワードは${signUpValidations.password.minLength}〜${signUpValidations.password.maxLength}文字で入力してください`}
    </span>;

    case "maxLength": return <span className="errorMessage">
      {`パスワードは${signUpValidations.password.minLength}〜${signUpValidations.password.maxLength}文字で入力してください`}
    </span>
  }
}



こうすることで、将来的にバリデーションが変更になったさいに、別ファイルの値を変えるだけで、全ての箇所が安全に修正できます。

動作確認



ここまできたら表示ができているはずです。
App.tsxでSignUpコンポーネントを表示してみましょう!

App.tsx

// - フレームワーク=======================================================================================================
import React, { VFC } from 'react';

// - コンポーネント =======================================================================================================
import { SignUp } from "./components/pages/SignUp/SignUp";


const App: VFC = () => {

  return (
    <>
      <SignUp/>
    </>
  );
}

export default App;


npm run start






こちらでうまくバリデーションが表示できているかと思います。
react hook formはエラーがあった際にスクロールとfocusも当ててくれるので超優秀ですね、、、、


必須のバッジはrequiredをfalseにすると非表示になります。

お疲れさまでした!!

完成コード


inputFieldのコード
親コンポーネントのコード

開発しているリポジトリの為、多少変化があるかもしれないです。