Reactでアプリを作成しました【6】【Web フォーム 】


## React Hook Formとは

[React Hook Form]
(https://react-hook-form.com/jp/)

『React Hook Form』 とは「高性能で柔軟かつ拡張可能な使いやすいフォームバリデーションライブラリ」を掲げた入力フォームの管理に特化した React 向けのライブラリ。
状態管理を DOM で行う非制御コンポーネントでフォームの値を扱うことで他よりも高速なフォームライブラリを可能にする。

##Material-UI とは

[React Stepper component - Material-UI]
(https://mui.com/components/steppers/)

Google の Material デザインをベースに開発された、UI コンポーネントライブラリです。
お手軽に Material デザインを取り入れられることに加えて、コンポーネントの種類が豊富に用意されているため、それらを組み合わせるだけでも見栄えの良いものを作ることができます。

##Create React App でアプリの雛形を作成

```
$ npx create-react-app <アプリ名>
$ cd <アプリ名>
```

##React Routerのインストール
事前にライブラリをインストールする。

```
$ npm install react-router-dom
```


##各ステップ毎の入力情報を 『保存』 及び 『取得』 する

ここまでの実装で基本項目や任意項目のフォーム（コンテンツ）は用意できたが、各フォームに入力された入力情報は各コンポーネントの react-hook-form がステートを保持したままの状態であり各ステップ毎の入力情報は、どこかに保存して管理しておく必要がある。そこで、React の『コンテクスト』を利用して各コンポーネントのデータを管理する。
[Reactを基本からまとめてみた【13】【React Context】]
(https://qiita.com/kanfutrooper/items/6e0bfe20fdf76796caa1)

Content.js や Basic.js および Optional.js を編集する。

```Content.js
:
snip
:
export const UserInputData = React.createContext();

function Content() {
    const [currentState, setCurrentState] = React.useState({});
    const value = {
        currentState,
        setCurrentState
    };
    :
    snip
    :
    return (
        <Grid container>
            <Grid sm={2}/>
            <Grid lg={8} sm={8} spacing={10}>
                <Stepper activeStep={activeStep} alternativeLabel>
                    {steps.map((label) => (
                        <Step key={label}>
                            <StepLabel>{label}</StepLabel>
                        </Step>
                    ))}
                </Stepper>
                <UserInputData.Provider value={value}>
                    { getStepContent(activeStep, handleNext, handleBack)}
                </UserInputData.Provider>
            </Grid>
        </Grid>
    )
}
export default Content
```

- UserInputData というコンテクストオブジェクトを、createContext() で作成
- 各ステップのフォームで入力された情報を保持するための currentState を useState() で作成
- UserInputData コンテクストオブジェクトのプロバイダコンポーネントに currentState と setCurrentState を含めた value プロパティ経由で、各ステップのコンテンツ（フォーム）コンポーネントに渡す

```Basic.js
:
snip
:
import React, { useContext } from "react";
import { UserInputData } from "./Content";

function Basic(props) {
    :
    snip
    :
    const { currentState, setCurrentState } = useContext(UserInputData);
    const onSubmit = (data) => {
        props.handleNext();
        setCurrentState({...currentState, "Basic": data });
    };
    :
    snip
    :
```

- Content.js から UserInputData を import
- useContext() を利用して UserInputData コンテクストオブジェクトのプロバイダコンポーネント経由で currentState と setCurrentState を受け取る
- onClick イベントで呼ばれる onSubmit() 関数内で、react-hook-form から受け取った入力データを setCurrentState を利用して currentState へ追加

```Optional.js
:
snip
:
import React, { useContext } from "react";
import { UserInputData } from "./Content";

function Optional(props) {
    const { control, handleSubmit, getValues } = useForm({
        defaultValues: {
            multilineText: "",
        },
    });
    const { currentState, setCurrentState } = useContext(UserInputData);
    const onSubmit = (action) => {
        if(action === 'back') {
            props.handleBack();
        } else {
            props.handleNext();
        }
        const data = getValues();
        setCurrentState({...currentState, "Optional": data });
    };
    return (
        <Grid container>
        :
        snip
        :
                    <Button
                        variant="contained"
                        color="primary"
                        onClick={() => onSubmit("back")}
                    >
                        戻る
                    </Button>
                    <Button
                        variant="contained"
                        color="primary"
                        type="submit"
                    >
                        次へ
                    </Button>

```

Basic.js と少し異なる点として「戻る」ボタンがクリックされた時も、
フォームの入力情報を保存するように追加で実装している。

React のステートとして、react-hook-form で入力された内容がボタンクリック後に保存されているかを確認するために、Chrome ブラウザと、React Developer Tools を利用して確認する。

```Confirm.js

import { Grid } from '@material-ui/core'
import React, { useContext } from "react";
import { Button } from "@material-ui/core";
import { UserInputData } from "./Content";
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Paper from '@material-ui/core/Paper';

var item = {
    'checkBox': 'チェックボックス',
    'textBox': 'テキストボックス',
    'pullDown': 'プルダウン',
    'multilineText': 'マルチラインテキスト'
}

function Confirm(props) {
    const { currentState } = useContext(UserInputData);
    const onSubmit = () => {
        alert(JSON.stringify(currentState));
    };
    const inputDataLists = [];
    var id = 0;
    for ( var k in currentState) {
        for ( var v in currentState[k]) {
            var value = ''
            if (currentState[k][v] === true) {
                value = 'チェックしました';
            } else if (currentState[k][v] === false) {
                value = 'チェックしていません';
            } else if (currentState[k][v] === '') {
                value = '未入力';
            } else {
                value = currentState[k][v];
            }
            inputDataLists.push(
                {
                    "id": id,
                    "name": item[v],
                    "value": value
                }
            );
            id++;
        }
    }
return (
        <Grid container>
            <TableContainer component={Paper}>
                <Table aria-label="Customer Input Data">
                    <TableHead>
                        <TableRow>
                            <TableCell>項目</TableCell>
                            <TableCell>入力内容</TableCell>
                        </TableRow>
                    </TableHead>
                    <TableBody>
                        {
                            inputDataLists.map(function(elem) {
                                return (
                                    <TableRow key={elem.id}>
                                    <TableCell>{elem.name}</TableCell>
                                    { elem.value ? <TableCell>{elem.value}</TableCell> : <TableCell>None</TableCell> }
                                    </TableRow>
                                )
                            })
                        }
                    </TableBody>
                </Table>
            </TableContainer>
            <Button variant="contained" color="primary" onClick={props.handleBack}>
                戻る
            </Button>
            <Button variant="contained" color="primary" onClick={onSubmit}>
                送信
            </Button>
        </Grid>
    )
}

export default Confirm
```
ブラウザから、基本項目および任意項目を記入し入力確認画面を確認する。

- 各フォーム内で入力されたデータを、Material-UI のテーブルとして表示させる
- 基本項目および任意項目で入力された情報は、currentState から取得する
- 「次へ」ボタンの代わりに、「送信」ボタンを設置

##バリデーション機能を追加

Yup をプロジェクトにインストールする。

```
$ npm install @hookform/resolvers yup
$ git diff -p
:
snip
:
diff --git a/package.json b/package.json
index f61e3cb..c8b846f 100644
--- a/package.json
+++ b/package.json
@@ -3,13 +3,17 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "@hookform/resolvers": "^2.6.1",
+    "@material-ui/core": "^4.12.2",
     "@testing-library/jest-dom": "^5.14.1",
     "@testing-library/react": "^11.2.7",
     "@testing-library/user-event": "^12.8.3",
     "react": "^17.0.2",
     "react-dom": "^17.0.2",
+    "react-hook-form": "^7.12.1",
     "react-scripts": "4.0.3",
-    "web-vitals": "^1.1.2"
+    "web-vitals": "^1.1.2",
+    "yup": "^0.32.9"
```

パッケージがインストールされ、package.json に追加したので、Basic.js のチェックボックス及び、テキストフィールドに入力を必須とするバリデーション機能を追加する。

```Basic.js
:
snip
:
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from "@material-ui/core/FormHelperText";
import { yupResolver } from '@hookform/resolvers/yup';
import * as Yup from 'yup';

function Basic(props) {
    const basicSchema = Yup.object().shape({
        checkBox: Yup.boolean()
            .oneOf([true], 'チェックが必要です'),
        textBox: Yup.string()
            .required('必須項目です')
        pullDown: Yup.string()
            .oneOf(['one', 'two', 'three'], 'いずれかを選択してください'),
    });  
    const { control, handleSubmit, formState:{ errors } } = useForm({
        mode: 'onBlur',
        defaultValues: {
            checkBox: false,
            textBox: "",
            pullDown: "",
        },
        resolver: yupResolver(basicSchema)
    });
    :
    snip
    :
return (
        <Grid container>
            <Grid sm={2}/>
            <Grid lg={8} sm={8} spacing={10}>
                <form onSubmit={handleSubmit(onSubmit)}>
                    <Controller
                        control={control}
                        name="checkBox"
                        render={({ field: { value, onChange } }) => (
                            <FormControl error>
                                <FormControlLabel
                                    control={
                                        <Checkbox
                                            checked={value}
                                            onChange={onChange}
                                            color='primary'
                                        />
                                    }
                                    label="チェックボックス"
                                />
                                <FormHelperText>
                                    { errors.checkBox?.message }
                                </FormHelperText>
                            </FormControl>
                        )}
                    />
                    <Controller
                        control={control}
                        name="textBox"
                        render={({ field }) => (
                            <TextField
                                {...field}
                                label="テキストフィールド"
                                error={errors.textBox ? true : false}
                                helperText={errors.textBox?.message}
                                fullWidth
                                margin="normal"
                                placeholder="プレースホルダー"
                            />
                        )}
                    />
                    <Controller
                        control={control}
                        name="pullDown"
                        render={({ field }) => (
                            <TextField
                                {...field}
                                label="プルダウンリスト"
                                error={errors.pullDown ? true : false}
                                helperText={errors.pullDown?.message}
                                fullWidth
                                margin="normal"
                                id="select"
                                select
                            >
                                <MenuItem value="one">選択肢1</MenuItem>
                                <MenuItem value="two">選択肢2</MenuItem>
                                <MenuItem value="three">選択肢3</MenuItem>
                            </TextField>
                        )}
                    />
```

ブラウザから基本項目のフォームを表示させ、『次へ』ボタンをクリックする。『次へ』のボタンをクリックしたにも関わらず、入力が必須化されているため任意項目のフォームへ遷移することができない。ソースコードの定義としては、下記の実装部分が該当する。oneOf() の第２引数や、required() の引数がエラーメッセージになる。

######※ basicSchema を react-hook-form で利用するために useForm() の resolver に設定することで、外部の検証用ライブラリを利用することができる。

```Basic.js
const basicSchema = Yup.object().shape({
        checkBox: Yup.boolean()
            .oneOf([true], 'チェックが必要です'),
        textBox: Yup.string()
            .required('必須項目です')
        pullDown: Yup.string()
            .oneOf(['one', 'two', 'three'], 'いずれかを選択してください'),
    });
```

#####* エラーメッセージを表示する部分は、下記のように実装する。

```Basic.js
const { control, handleSubmit, formState:{ errors } } = useForm({
        mode: 'onBlur',
        defaultValues: {
            checkBox: false,
            textBox: "",
            pullDown: "",
        },
        resolver: yupResolver(basicSchema)
    });
```

#####※チェックボックス側は、FormControl コンポーネントと FormHelperText コンポーネントを利用する。

```Basic.js
<Controller
                        control={control}
                        name="checkBox"
                        render={({ field: { value, onChange } }) => (
                            <FormControl error>
                                <FormControlLabel
                                    control={
                                        <Checkbox
                                            checked={value}
                                            onChange={onChange}
                                            color='primary'
                                        />
                                    }
                                    label="チェックボックス"
                                />
                                <FormHelperText>
                                    { errors.checkBox?.message }
                                </FormHelperText>
                            </FormControl>
                        )}
                    />
```

####各 API の詳細については、Material-UI の公式ドキュメントを参照すること
[FormControl API]
(https://mui.com/api/form-control/)
[FormHelperText API]
(https://mui.com/api/form-helper-text/)


#####※チェックボックスとは異なり、テキストフィールドやプルダウンリストは２行（error/helperText プロパティ）追加するだけで対応できる。

```Basic.js
<Controller
                        control={control}
                        name="textBox"
                        render={({ field }) => (
                            <TextField
                                {...field}
                                label="テキストフィールド"
                                error={errors.textBox ? true : false}
                                helperText={errors.textBox?.message}
                                fullWidth
                                margin="normal"
                                placeholder="プレースホルダー"
                            />
                        )}
                    />
                    <Controller
                        control={control}
                        name="pullDown"
                        render={({ field }) => (
                            <TextField
                                {...field}
                                label="プルダウンリスト"
                                error={errors.pullDown ? true : false}
                                helperText={errors.pullDown?.message}
                                fullWidth
                                margin="normal"
                                id="select"
                                select
                            >
                                <MenuItem value="one">選択肢1</MenuItem>
                                <MenuItem value="two">選択肢2</MenuItem>
                                <MenuItem value="three">選択肢3</MenuItem>
                            </TextField>
                        )}
                    />
```

####各 API の詳細については、Material-UI の公式ドキュメントを参照すること
[TextField API]
(https://mui.com/api/text-field/)

テキストフィールドはいくつか制限を設ける。入力内容を「半角英数字記号」に制限する。

```Basic.js
const basicSchema = Yup.object().shape({
        :
        snip
        :
        textBox: Yup.string()
            .required('必須項目です')
            .matches(/^[a-zA-Z0-9!-/:-@¥[-`{-~ ]*$/, "半角英数字記号以外は使用できません")
        :
        snip
        :
    });
```

matches() の第１引数で指定した正規表現にマッチする必要があるという制限を設けたため、全角文字を入力した場合など、にエラーとして扱われる。

文字数制限も追加する。

```Basic.js
const basicSchema = Yup.object().shape({
        checkBox: Yup.boolean()
            .oneOf([true], 'チェックが必要です'),
        textBox: Yup.string()
            .required('必須項目です')
            .max(10, '10文字以内で入力してください')
            .matches(/^[a-zA-Z0-9!-/:-@¥[-`{-~ ]*$/, "半角英数字記号以外は使用できません")
        :
        snip
        :
    });
```
max() の第１引数で指定した数値以内の文字数でない場合、エラーとして扱われる。


##入力確認画面の送信ボタンを実装する

Web フォームとして利用するために、入力確認画面の送信ボタンをクリックした際に外部の API エンドポイントへ入力された内容（JSON 情報）を POST できるようにする。
具体的には、Confirm.js の onSubmit() に手を加える。

#####変更前

```Confirm.js
const onSubmit = () => {
        alert(JSON.stringify(currentState));
    };
```
入力確認画面の送信ボタンをクリックした際の動作は currentState の JSON データをアラートで表示しているだけを下記のように修正する。

#####変更後

```Confirm.js
const onSubmit = () => {
        postData();
    };
    async function postData() {
        const res = await fetch(
            'https://example.com/api', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(currentState)
            }
        );
    }
```

修正後は、入力確認画面の送信ボタンをクリックした際に currentState の JSON データを https://example.com/api という架空の API エンドポイントへ POST リクエストを行う非同期処理に変更する。本番利用する際は、実際に入力された情報を処理するためのシステム等で利用される HTTP エンドポイント（URL）へ置き換える必要がある。

##データ送信に失敗した際のエラー通知を追加

エラー通知は、react-hot-toast というライブラリを利用する。
[react-hot-toast]
(https://react-hot-toast.com/)

 react-hot-toast パッケージをインストールする。

```
$ npm install react-hot-toast
```
Confirm.js を編集する。

```Confirm.js
:
snip
:
import toast, { Toaster } from 'react-hot-toast';
:
snip
:
function Confirm(props) {
    const { currentState } = useContext(UserInputData);
    const notifyError = () => toast.error('データの送信に失敗しました。少し待ってからリトライしてください');
    const onSubmit = () => {
        postData()
        .then(data => {
            console.log(JSON.stringify(data));
        })
        .catch(err => {
            notifyError();
            console.log(err);
        });
    };
    async function postData() {
        const res = await fetch(
            'https://example.com/api', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(currentState)
            }
        );
        const data = await res.json();
        return data;
    }
    :
    snip
    :
    return (
        <Grid container>
            <Toaster position="top-right" duration="4000" />
            <TableContainer component={Paper}>
```

#####変更点は、下記の5つ

- react-hot-toast を import
- toast() API を呼び出す notifyError 関数を定義
- postData 関数で、fetch したレスポンスデータを JSON 形式で返却
- onSubmit 関数内で、postData() 呼び出しがエラーの場合に notifyError() を実行
- Toaster コンポーネントを配置

Confirm.js を編集で、エラー通知が実装できた。

toast() API 等の詳細については、公式ドキュメントを確認する。
[toast() API - react-hot-toast]
(https://react-hot-toast.com/docs/toast)

##データ送信が成功した際のサンクスページを追加

ソースファイルを作成する。

```
$ touch src/components/Thanks.js
```

```Thanks.js
import { Grid } from '@material-ui/core'
import Typography from '@material-ui/core/Typography';

function Thanks() {
    return (
        <Grid container alignItems="center" justifyContent="center">
            <Typography variant="h4">
                ありがとうございました
            </Typography>
        </Grid>
    )
}

export default Thanks
```

Confirm コンポーネントのプロパティとして handleNext を渡す。

```Content.js
function getStepContent(stepIndex, handleNext, handleBack) {
    switch (stepIndex) {
        case 0:
            return <Basic handleNext={handleNext} />;
        case 1:
            return <Optional handleNext={handleNext} handleBack={handleBack} />;
        case 2:
            return <Confirm handleNext={handleNext} handleBack={handleBack} />;            
        default:
            return 'Unknown stepIndex';
    }
}
```

postData() が成功した際に、props.handleNext() を呼び出す。

```Confirm.js
function Confirm(props) {
    :
    const onSubmit = () => {
        postData()
        .then(data => {
            console.log(JSON.stringify(data));
            props.handleNext();
        })
```

activeStep と steps.length が同じ場合に Thanks コンポーネントを表示させるように条件分岐を追加する。

```Content.js
:
snip
:
import Thanks from "./Thanks";
    :
    snip
    :
    return (
        <Grid container>
            <Grid sm={2}/>
            <Grid lg={8} sm={8} spacing={10}>
                <Stepper activeStep={activeStep} alternativeLabel>
                    {steps.map((label) => (
                        <Step key={label}>
                            <StepLabel>{label}</StepLabel>
                        </Step>
                    ))}
                </Stepper>
                {activeStep === steps.length ? (
                    <Thanks />
                ) : (
                    <UserInputData.Provider value={value}>
                        { getStepContent(activeStep, handleNext, handleBack)}
                    </UserInputData.Provider>
                )}
            </Grid>
        </Grid>
    )
```





##参考サイト
[React 初心者が Material-UI で今どきの Web フォームを作ってみた（Stepper編）]
(https://dev.classmethod.jp/articles/react-beginners-tried-to-create-a-modern-web-form-with-material-ui-stepper/#toc-1)
[React Hook Form(V7)を使って簡単にバリデーションを実装しよう！]
(https://www.asobou.co.jp/blog/web/reacthookform)
[React 初心者が Material-UI で今どきの Web フォームを作ってみた（react-hook-form編）]
(https://dev.classmethod.jp/articles/react-beginners-tried-to-create-a-modern-web-form-with-material-ui-and-react-hook-form/)
[React 初心者が Material-UI で今どきの Web フォームを作ってみた（yup編）]
(https://dev.classmethod.jp/articles/react-beginners-tried-to-create-a-modern-web-form-with-material-ui-and-yup/)
[React Hook Form - スキーマバリデーション]
(https://dev.classmethod.jp/articles/react-beginners-tried-to-create-a-modern-web-form-with-material-ui-and-yup/)