react hook formを使ってInputFieldのコンポーネントを作成しよう!
フロントでフォームのバリデーションをかける。
これはフロントエンドを触っていれば必ず発生する局面です。
今回は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} // 今回はtextかpasswordかemailです。
placeholder={placeholder}
disabled={disabled}
{...register(`${name}`, { // ここでregisterにnameを渡しています。
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のコード
親コンポーネントのコード
開発しているリポジトリの為、多少変化があるかもしれないです。