サインアップフォームを作成する!(with React Hook Form & yup)

28 min read読了の目安(約25400字

Material-UIReact Templates にあるSign UpReact Hook Form などを利用し、カスタマイズしました。参考になれば幸いです!


目標

  • パスワードを視覚化できるようにする。
  • 必須項目が空欄の場合や正規表現による条件を満たさない場合、エラーが出るようにする。
  • パスワードの長さによって、安全なパスワードかどうか判定できるようにする。
  • JSON 形式で送信できる(アラートを出す)ようにし、送信後はフォーム欄を空白にする。

この4点の達成を目指します。


必要なパッケージを導入

以下、 create-react-app でプロジェクトを作成後を想定しています。

yarn add @material-ui/core @material-ui/icons

ベースとなるコード

冒頭でも述べたように、GitHubにて公開されている sign-up のコードをベースとします。

ファイル構成を確認しておきましょう。

├── src/
   ├── components/
           ├──signup/
                 └──signUp.style.ts
                 └──SignUp.tsx 
   └──App.tsx		 
   └──index.tsx

ベースとなるコードです。

SignUp.tsx
import React from "react";
import Avatar from "@material-ui/core/Avatar";
import Button from "@material-ui/core/Button";
import CssBaseline from "@material-ui/core/CssBaseline";
import TextField from "@material-ui/core/TextField";
import Grid from "@material-ui/core/Grid";
import Box from "@material-ui/core/Box";
import LockOutlinedIcon from "@material-ui/icons/LockOutlined";
import Typography from "@material-ui/core/Typography";
import Container from "@material-ui/core/Container";
import { FormControl, InputLabel, OutlinedInput } from "@material-ui/core";
import useStyles from "./signUp.style";
import CopyRight from "../CopyRight";
import { useState } from "react";

export default function SignUp() {
  const classes = useStyles();

  const [values, setValue] = useState({
    firstName: "",
    lastName: "",
    email: "",
    password: ""
  });

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;

    setValue({
      ...values,
      [name]: value
    });
  };

  return (
    <Container component="main" maxWidth="xs">
      <CssBaseline />
      <div className={classes.paper}>
        <Avatar className={classes.avatar}>
          <LockOutlinedIcon />
        </Avatar>
        <Typography component="h1" variant="h5">
          Sign up
        </Typography>
        <form className={classes.form} noValidate>
          <Grid container spacing={2}>
            <Grid item xs={12} sm={6}>
              <TextField
                autoComplete="fname"
                name="firstName"
                variant="outlined"
                required
                fullWidth
                id="firstName"
                label="First Name"
                onChange={handleInputChange}
                value={values.firstName}
                autoFocus
              />
            </Grid>
            <Grid item xs={12} sm={6}>
              <TextField
                variant="outlined"
                required
                fullWidth
                id="lastName"
                label="Last Name"
                onChange={handleInputChange}
                value={values.lastName}
                name="lastName"
                autoComplete="lname"
              />
            </Grid>
            <Grid item xs={12}>
              <TextField
                variant="outlined"
                required
                fullWidth
                id="email"
                label="Email Address"
                onChange={handleInputChange}
                value={values.email}
                name="email"
                autoComplete="email"
              />
            </Grid>
            <Grid item xs={12}>
              <FormControl className={classes.inputField} variant="outlined">
                <InputLabel htmlFor="outlined-adornment-password">
                  password
                </InputLabel>
                <OutlinedInput
                  id="password"
                  name="password"
                  onChange={handleInputChange}
                  value={values.password}
                  labelWidth={80}
                />
              </FormControl>
            </Grid>
          </Grid>
          <Button
            type="submit"
            fullWidth
            variant="contained"
            color="primary"
            className={classes.submit}
          >
            Sign Up
          </Button>
        </form>
      </div>
      <Box mt={5}>
        <CopyRight />
      </Box>
    </Container>
  );
}



パスワードを視覚化

SignUp.tsx
​import React from "react";
+// Material-UI関連の import は省略します。

import useStyles from "./signUp.style";
import CopyRight from "../CopyRight";
import { useState } from "react";

type FormValues = {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
+ showPassword: boolean;
};

const SignUp = () => {
  const classes = useStyles();

  const [values, setValue] = useState<FormValues>({
    firstName: "",
    lastName: "",
    email: "",
    password: "",
+   showPassword: false
  });

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;

    setValue({
      ...values,
      [name]: value
    });
  };

+  const handleClickShowPassword = () => {
+    setValue({
+      ...values, //showPassword プロパティ以外は変更を加えない。
+      showPassword: !values.showPassword
+    });
+  };

  return (
    <Container component="main" maxWidth="xs">
      <CssBaseline />
      <div className={classes.paper}>
        <Avatar className={classes.avatar}>
          <LockOutlinedIcon />
        </Avatar>
        <Typography component="h1" variant="h5">
          Sign up
        </Typography>
        <form className={classes.form} noValidate>
          <Grid container spacing={2}>
            <Grid item xs={12} sm={6}>
              <TextField
                autoComplete="fname"
                name="firstName"
                variant="outlined"
                required
                fullWidth
                id="firstName"
                label="First Name"
                onChange={handleInputChange}
                value={values.firstName}
                autoFocus
              />
            </Grid>
            <Grid item xs={12} sm={6}>
              <TextField
                variant="outlined"
                required
                fullWidth
                id="lastName"
                label="Last Name"
                onChange={handleInputChange}
                value={values.lastName}
                name="lastName"
                autoComplete="lname"
              />
            </Grid>
            <Grid item xs={12}>
              <TextField
                variant="outlined"
                required
                fullWidth
                id="email"
                label="Email Address"
                onChange={handleInputChange}
                value={values.email}
                name="email"
                autoComplete="email"
              />
            </Grid>
            <Grid item xs={12}>
              <FormControl className={classes.inputField} variant="outlined">
                <InputLabel htmlFor="outlined-adornment-password">
                  password
                </InputLabel>
                <OutlinedInput
                  id="password"
                  name="password"
+                 type={values.showPassword ? "text" : "password"} //フォームのタイプを三項演算子でテキストかパスワードか使い分ける。
                  onChange={handleInputChange}
                  value={values.password}
                  labelWidth={80}
+                 endAdornment={
+                   <InputAdornment position="end">
+                     <IconButton
+                       aria-label="toggle password visibility"
+                       onClick={handleClickShowPassword}
+                       edge="end"
+                     >
+                       {values.showPassword ? (
+                         <Visibility />
+                       ) : (
+                         <VisibilityOff />
+                       )}
+                     </IconButton>
+                   </InputAdornment>
+                 }
                />
              </FormControl>
            </Grid>
          </Grid>
          <Button
            type="submit"
            fullWidth
            variant="contained"
            color="primary"
            className={classes.submit}
          >
            Sign Up
          </Button>
        </form>
      </div>
      <Box mt={5}>
        <CopyRight />
      </Box>
    </Container>
  );
}

export default SignUp;

ポイント

  1. useState による状態管理に showPassword プロパティを追加。
  2. handleClickShowPassword 関数を作成。 showPassword の false ←→ true を実現。
  3. Material-UIOutlinedInputタグの endAdornment プロパティの中に VisibilityVisibilityOff アイコンを追加。

必須項目が空欄の場合、エラーを抽出 & バリデート

必要なパッケージを導入します。

yarn add react-hook-form yup @hookform/resolvers
TypeScriptの場合はこちらも必要です
yarn add -D @types/yup
SignUp.tsx
​import React from "react";
import useStyles from "./signUp.style";
import CopyRight from "../CopyRight";
import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { yupResolver } from "@hookform/resolvers/yup";
+import * as yup from "yup";

type FormValues = {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  showPassword: boolean;
};

+const schema = yup.object().shape({
+  firstName: yup
+    .string()
+    .matches(/^([^0-9]*)$/, "数字は使用できません")
+    .required("氏名欄は必須項目です"),
+  lastName: yup
+    .string()
+    .matches(/^([^0-9]*)$/, "数字は使用できません")
+    .required("名前欄は必須項目です"),
+  email: yup
+    .string()
+    .lowercase()
+    .email("正しいメールアドレスを指定してください。")
+    .required("メールアドレスは必須項目です"),
+  password: yup
+    .string()
+    .matches(/(?=.*[a-z])/, "小文字を含めてください")
+    .matches(/(?=.*[A-Z])/, "大文字を含めてください")
+    .matches(/(?=.*[0-9])/, "数字を含めてください")
+    .min(8, "最低8文字含めてください")
+    .required("パスワードは必須項目です")
+});

const SignUp = () => {
  const classes = useStyles();

  const [values, setValue] = useState<FormValues>({
    firstName: "",
    lastName: "",
    email: "",
    password: "",
    showPassword: false
  });

+ const { register, handleSubmit, errors } = useForm({
+   mode: "onBlur",
+   resolver: yupResolver(schema)
+ });

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;

    setValue({
      ...values,
      [name]: value
    });
  };

  const handleClickShowPassword = () => {
    setValue({
      ...values,
      showPassword: !values.showPassword
    });
  };

  return (
    <Container component="main" maxWidth="xs">
      <CssBaseline />
      <div className={classes.paper}>
        <Avatar className={classes.avatar}>
          <LockOutlinedIcon />
        </Avatar>
        <Typography component="h1" variant="h5">
          Sign up
        </Typography>
        <form className={classes.form} noValidate>
          <Grid container spacing={2}>
            <Grid item xs={12} sm={6}>
              <TextField
                autoComplete="firstname"
                autoFocus
+               error={!!errors.firstName}// boolean 型に変更
                fullWidth
+               helperText={errors.firstName?.message}
                id="firstname"
+               inputRef={register}
                label="氏名"
                name="firstName"
                onChange={handleInputChange}
                value={values.firstName}
                required
                variant="outlined"
              />
            </Grid>
            <Grid item xs={12} sm={6}>
              <TextField
                autoComplete="lastname"
+               error={!!errors.lastName}// boolean 型に変更
                fullWidth
+               helperText={errors.lastName?.message}
                id="lastname"
+               inputRef={register}
                label="名前"
                name="lastName"
                onChange={handleInputChange}
                required
                value={values.lastName}
                variant="outlined"
              />
            </Grid>
            <Grid item xs={12}>
              <TextField
                autoComplete="email"
+               error={!!errors.email}// boolean 型に変更
                fullWidth
+               helperText={errors.email?.message}
                id="email"
+               inputRef={register}
                label="メールアドレス"
                name="email"
                onChange={handleInputChange}
                required
                value={values.email}
                variant="outlined"
              />
            </Grid>
            <Grid item xs={12}>
              <FormControl className={classes.inputField} variant="outlined">
                  <InputLabel
                    htmlFor='outlined-adornment-password'
+                   error={!!errors.password}// boolean 型に変更
                  >
                    パスワード
                  </InputLabel>
                <OutlinedInput
                  id="password"
+                 error={!!errors.password}// boolean 型に変更
                  name="password"
                  type={values.showPassword ? "text" : "password"}
                  onChange={handleInputChange}
+                 inputRef={register}
                  value={values.password}
                  endAdornment={
                    <InputAdornment position="end">
                      <IconButton
                        aria-label="toggle password visibility"
                        onClick={handleClickShowPassword}
                        edge="end"
                      >
                        {values.showPassword ? (
                          <Visibility />
                        ) : (
                          <VisibilityOff />
                        )}
                      </IconButton>
                    </InputAdornment>
                  }
                  labelWidth={80}
                />
+                 {errors.password && (
+                   <FormHelperText error={!!errors.password}>
+                     {errors.password.message}
+                   </FormHelperText>
+                 )}
              </FormControl>
            </Grid>
          </Grid>
          <Button
            type="submit"
            fullWidth
            variant="contained"
            color="primary"
            className={classes.submit}
          >
            Sign Up
          </Button>
        </form>
      </div>
      <Box mt={5}>
        <CopyRight />
      </Box>
    </Container>
  );
};

export default SignUp;

フォームのバリデーションには yup というライブラリを導入します。
GitHubREADME にも書かれていますが、
yupとは

Yup is a JavaScript schema builder for value parsing and validation.

つまり

フォームの入力値を解析してバリデーションを行うために、JavaScriptでスキーマ(データ構造)を定義するためのライブラリ

正規表現を用いてバリデーションを行っているのが、この部分です。

SignUp.tsxの一部
const schema = yup.object().shape({
  firstName: yup
    .string()
    .matches(/^([^0-9]*)$/, "数字は使用できません")
    .required("氏名欄は必須項目です"),
  lastName: yup
    .string()
    .matches(/^([^0-9]*)$/, "数字は使用できません")
    .required("名前欄は必須項目です"),
  email: yup
    .string()
    .lowercase()
    .email("正しいメールアドレスを指定してください。")
    .required("メールアドレスは必須項目です"),
  password: yup
    .string()
    .matches(/(?=.*[a-z])/, "小文字を含めてください")
    .matches(/(?=.*[A-Z])/, "大文字を含めてください")
    .matches(/(?=.*[0-9])/, "数字を含めてください")
    .min(8, "最低8文字含めてください")
    .required("パスワードは必須項目です")
});

const SignUp = () => {

  const { register, handleSubmit, errors } = useForm({
    mode: "onBlur",//バリデーションのタイミングを各入力フォームから外れたときに実行。
    resolver: yupResolver(schema)
  });
  
};

export default SignUp;
  

ポイント

  1. yup.object() は判定対象の入力フォームの値がオブジェクトで提供されること定義する。また、データ構造を .shape() で定義する。
  2. yup.string().required(エラーメッセージ) で文字列型の必須項目であることを実現。
  3. .matches(正規表現,エラーメッセージ) で正規表現による条件に合わなければエラーを提示。
  4. useForm の引数である resolver という関数を使用すると、外部のバリデーションメソッドを実行。 yupResolver(schema) で上述の yup のルールを採用。

また React Hook Form から分割代入を用いて、 register , handleSubmit , error を取り出します。
これら3つのプロパティについては、公式で詳しく述べられています。

register

https://react-hook-form.com/jp/api/#register

handleSubmit

https://react-hook-form.com/jp/api/#handleSubmit

error

https://react-hook-form.com/jp/api/#errors

以上、コードの変更によりエラーの機能を導入できました。

useForm に型を定義

現時点で React Hook Form から取り出した3つのメソッドに型が定義されていないため、例えばerrors のあとに候補が出てきません。

SignUp.tsxの一部
const schema = yup.object().shape({
  firstName: yup
    .string()
    .matches(/^([^0-9]*)$/, "数字は使用できません")
    .required("氏名欄は必須項目です"),
  lastName: yup
    .string()
    .matches(/^([^0-9]*)$/, "数字は使用できません")
    .required("名前欄は必須項目です"),
  email: yup
    .string()
    .lowercase()
    .email("正しいメールアドレスを指定してください。")
    .required("メールアドレスは必須項目です"),
  password: yup
    .string()
    .matches(/(?=.*[a-z])/, "小文字を含めてください")
    .matches(/(?=.*[A-Z])/, "大文字を含めてください")
    .matches(/(?=.*[0-9])/, "数字を含めてください")
    .min(8, "最低8文字含めてください")
    .required("パスワードは必須項目です")
});

+ type FormData = yup.InferType<typeof schema>;//   firstName: string; lastName: string; email: string; password: string; と同義
​
const SignUp = () => {
​
- const { register, handleSubmit, errors } = useForm({
+ const { register, handleSubmit, errors } = useForm<FormData>({  
    mode: "onBlur",//バリデーションのタイミングを各入力フォームから外れたときに実行。
    resolver: yupResolver(schema)
  });
  
};
​
export default SignUp;

以上の変更により、候補が推測されます。


パスワードの長さによって、安全なパスワードかどうか判定

必要なパッケージ zxcvbn 導入します。

yarn add zxcvbn
TypeScript の場合はこちらも必要です
yarn add -D @types/zxcvbn

コンポーネントを分けるため、ファイル構成にも変更を加えます。

├── src/
   ├── components/
           ├──signup/
                 └──signUp.style.ts
                 └──SignUp.tsx
	  PasswordMeter.tsx  ←New	 
   └──App.tsx		 
   └──index.tsx

以下、 SignUp コンポーネントの変更と PasswordMeter コンポーネントの新規作成です。

SignUp.tsxの一部
const SignUp = () => {

  return (

              <Grid item xs={12}>
                <FormControl className={classes.inputField} variant='outlined'>
                  <InputLabel
                    htmlFor='outlined-adornment-password'
                    error={!!errors.password}
                  >
                    パスワード
                  </InputLabel>
                  <OutlinedInput
                    id='password'
                    error={!!errors.password}
                    name='password'
                    type={values.showPassword ? "text" : "password"}
                    onChange={handleInputChange}
                    inputRef={register}
                    value={values.password}
                    endAdornment={
                      <InputAdornment position='end'>
                        <IconButton
                          aria-label='toggle password visibility'
                          onClick={handleClickShowPassword}
                          edge='end'
                        >
                          {values.showPassword ? (
                            <Visibility />
                          ) : (
                            <VisibilityOff />
                          )}
                        </IconButton>
                      </InputAdornment>
                    }
                    labelWidth={80}
                  />
                  {errors.password && (
                    <FormHelperText error={!!errors.password}>
                      {errors.password.message}
                    </FormHelperText>
                  )}
                </FormControl>
+               {values.password ? (
+                 <PasswordMeter password={values.password} />
+               ) : null}
              </Grid>

  );
};

PasswordMeter.tsx
import zxcvbn from "zxcvbn";

type Props = {
  password: string;
};

const PasswordMeter = ({ password }: Props) => {
  const testResult = zxcvbn(password);
  const num = (testResult.score * 100) / 4;

  const createPassLabel = () => {
    switch (testResult.score) {
      case 0:
        return "Very weak";
      case 1:
        return "Weak";
      case 2:
        return "Fear";
      case 3:
        return "Good";
      case 4:
        return "Strong";
      default:
        return "";
    }
  };

  const changeColor = () => {
    switch (testResult.score) {
      case 0:
        return "#828282";
      case 1:
        return "#EA1111";
      case 2:
        return "#FFAD00";
      case 3:
        return "#9bc158";
      case 4:
        return "#00b500";
      default:
        return "none";
    }
  };

  const changePasswordColor = () => ({
    width: `${num}%`,
    background: changeColor(),
    height: "7px",
  });

  return (
    <>
      <div className='progress' style={{ height: "7px" }}>
        <div className='progress-bar' style={changePasswordColor()}></div>
      </div>
      <div style={{ display: "flex", justifyContent: "space-around" }}>
        <p>パスワードの強度 </p>
        <p style={{ color: changeColor() }}>{createPassLabel()}</p>
      </div>
    </>
  );
};

export default PasswordMeter;

ポイント

  1. useStateで状態管理している passwordProps 経由で PasswordMeter コンポーネントに渡す。
  2. zxcvbn の第一引数には文字列を渡す。
  3. PasswordMeter コンポーネント内、定数である testResult.scorenumpassword の文字の長さによって値が変化する。
password testResult.score num
0 ~ 3 文字 0 0
4 ~ 6 文字 1 25
7 ~ 8 文字 2 50
9 ~ 10 文字 3 75
11 ~ 文字 4 100

あとはスタイリングで width に定数 num を与えることで文字の長さによってパラメータが変化するといった具合です。


送信後、フォーム欄を空欄にする

JSON 形式でデータをアラートで呼び出すのに、React Hook FormhandleSubmit を使用します。

SignUp.tsx の一部
const SignUp = () => {

  const onSubmit = handleSubmit((data) => {
    alert(JSON.stringify(data));
  });
  
  return (
  
    <form onSubmit={onSubmit} className={classes.form}>
    
   //氏名、名前、メールアドレス、パスワードのフォームを定義

    </form>

  );
};
 
export default SignUp;

先程、React Hook Form に型を定義したことで、 handleSubmit 関数の引数 data にはしっかりと型が推測されています。

次に、送信後フォーム欄を空欄にする機能を追加します。

SignUp.tsx の一部
const SignUp = () => {

+ const [formKey, setFormKey] = useState(0);//1.

  const onSubmit = handleSubmit((data) => {
    alert(JSON.stringify(data));
+   setFormKey((prev) => prev + 1);//2.
+   setValue({ //3.
+     firstName: "",
+     lastName: "",
+     email: "",
+     password: "",
+     showPassword: false,
+   });
  });
  
  return (
  
-   <form onSubmit={onSubmit} className={classes.form}>
+   <form onSubmit={onSubmit} className={classes.form} key={formKey}>//4.
    </form>
  );
};
 
export default SignUp;
​

ポイント

  1. number 型として formKey に状態を持たせる。
  2. サインアップボタンを押したあと(送信後)、 formKey の値が +1 される。
  3. フォームの値を初期状態(空欄)にする。
  4. フォームタグに key プロパティを与えることで、新しいフォームへと描画される。

React Hook Form には reset という関数が用意されているのですが、うまく使いこなすことができませんでした😭


独自フックの作成

最後にSignUpコンポーネントはViewとロジックが大変混在しているため、ロジックを少しでも切り分けたカスタムフックを作成します。

新たに hooks フォルダーを追加します。

├── src/
   ├── components/
           ├──signup/
                 └──signUp.style.ts
                 └──SignUp.tsx
	  ├──hooks/
	         └──useFormState.ts  ←New  
          PasswordMeter.tsx        
   └──App.tsx            
   └──index.tsx

useFormState.tsファイルには、SignUpコンポーネントからロジックを引っ張ってきます。

useFormState.ts
import React, { useState } from "react";

type FormValues = {
  firstName: string;
  lastName: string;
  email: string;
  password: string;
  showPassword: boolean;
};

const useFormState = () => {
  const [values, setValue] = useState<FormValues>({
    firstName: "",
    lastName: "",
    email: "",
    password: "",
    showPassword: false,
  });

  const handleClickShowPassword = () => {
    setValue({
      ...values,
      showPassword: !values.showPassword,
    });
  };

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = event.target;

    setValue({
      ...values,
      [name]: value,
    });
  };

  return { values, setValue, handleClickShowPassword, handleInputChange };
};

export default useFormState;

あとはSignUpコンポーネントでuseFormState関数を呼び出すだけです。

SignUp.tsx
const SignUp = () => {
  const classes = useStyles();

-  const [values, setValue] = useState<FormValues>({
-    firstName: "",
-    lastName: "",
-    email: "",
-    password: "",
-    showPassword: false
-  });

-  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) --=> {
-    const { name, value } = event.target;
-
-    setValue({
-      ...values,
-      [name]: value
-    });
-  };

-  const handleClickShowPassword = () => {
-    setValue({
-      ...values, //showPassword プロパティ以外は変更を加えない。
-      showPassword: !values.showPassword
-    });
-  };
  
+  const {
+    values,
+    setValue,
+    handleInputChange,
+    handleClickShowPassword,
+  } = useFormState();


return ();
}
export default SignUp;

以上になります。ここまで読んでいただきありがとうございました!!