サインアップフォームを作成する!(with React Hook Form & yup)
Material-UI の React Templates にあるSign Upを React Hook Form などを利用し、カスタマイズしました。参考になれば幸いです!
目標
- パスワードを視覚化できるようにする。
- 必須項目が空欄の場合や正規表現による条件を満たさない場合、エラーが出るようにする。
- パスワードの長さによって、安全なパスワードかどうか判定できるようにする。
-
JSON
形式で送信できる(アラートを出す)ようにし、送信後はフォーム欄を空白にする。
この4点の達成を目指します。
必要なパッケージを導入
yarn add @material-ui/core @material-ui/icons
ベースとなるコード
冒頭でも述べたように、GitHub
にて公開されている sign-up のコードをベースとします。
ファイル構成を確認しておきましょう。
├── src/
├── components/
├──signup/
└──signUp.style.ts
└──SignUp.tsx
└──App.tsx
└──index.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>
);
}
パスワードを視覚化
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;
ポイント
-
useState
による状態管理に showPassword プロパティを追加。 - handleClickShowPassword 関数を作成。 showPassword の
false ←→ true
を実現。 -
Material-UI
のOutlinedInputタグのendAdornment
プロパティの中にVisibility
とVisibilityOff
アイコンを追加。
必須項目が空欄の場合、エラーを抽出 & バリデート
必要なパッケージを導入します。
yarn add react-hook-form yup @hookform/resolvers
yarn add -D @types/yup
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 というライブラリを導入します。
GitHub
の README
にも書かれていますが、
yup
とは
Yup is a JavaScript schema builder for value parsing and validation.
つまり
フォームの入力値を解析してバリデーションを行うために、JavaScriptでスキーマ(データ構造)を定義するためのライブラリ
正規表現を用いてバリデーションを行っているのが、この部分です。
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;
ポイント
-
yup.object()
は判定対象の入力フォームの値がオブジェクトで提供されること定義する。また、データ構造を.shape()
で定義する。 -
yup.string().required(エラーメッセージ)
で文字列型の必須項目であることを実現。 -
.matches(正規表現,エラーメッセージ)
で正規表現による条件に合わなければエラーを提示。 -
useForm
の引数であるresolver
という関数を使用すると、外部のバリデーションメソッドを実行。yupResolver(schema)
で上述のyup
のルールを採用。
また React Hook Form
から分割代入を用いて、 register
, handleSubmit
, error
を取り出します。
これら3つのプロパティについては、公式で詳しく述べられています。
register
↓
handleSubmit
↓
error
↓
以上、コードの変更によりエラーの機能を導入できました。
useForm に型を定義
現時点で React Hook Form
から取り出した3つのメソッドに型が定義されていないため、例えばerrors のあとに候補が出てきません。
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
yarn add -D @types/zxcvbn
コンポーネントを分けるため、ファイル構成にも変更を加えます。
├── src/
├── components/
├──signup/
└──signUp.style.ts
└──SignUp.tsx
PasswordMeter.tsx ←New
└──App.tsx
└──index.tsx
以下、 SignUp コンポーネントの変更と PasswordMeter コンポーネントの新規作成です。
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>
);
};
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;
ポイント
-
useState
で状態管理しているpassword
をProps
経由でPasswordMeter
コンポーネントに渡す。 -
zxcvbn
の第一引数には文字列を渡す。 -
PasswordMeter
コンポーネント内、定数であるtestResult.score
やnum
はpassword
の文字の長さによって値が変化する。
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 Form
の handleSubmit
を使用します。
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
にはしっかりと型が推測されています。
次に、送信後フォーム欄を空欄にする機能を追加します。
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;
ポイント
- number 型として
formKey
に状態を持たせる。 - サインアップボタンを押したあと(送信後)、
formKey
の値が +1 される。 - フォームの値を初期状態(空欄)にする。
- フォームタグに
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
コンポーネントからロジックを引っ張ってきます。
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
関数を呼び出すだけです。
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;
以上になります。ここまで読んでいただきありがとうございました!!
Discussion
僕もzodを使ってサインアップフォームを作ってみました。
デモコードです。
簡単ですが、以上です。