React 初心者が Material-UI で今どきの Web フォームを作ってみた(react-hook-form編)

React 初心者が、Material-UI と React Hook Form v7 を活用して今どきの Web フォーム開発に挑んでみました!
2021.07.30

この7月からカスタマーサクセスという役割にロールチェンジしました、下田です。

つい先日、React(+ React Hook Form)と Material-UI を組み合わせた Web アプリ開発を始めました。 Web アプリ開発の初心者でも簡単に、かつ今っぽい Web フォームを開発することができたので、少しコードを交えてご紹介してみたいと思います。

なお本記事は、前の記事(Stepper編)に続く形式となりますので、お時間あれば下記の記事も合わせてご参照いただけますと幸いです。

作ってみた

筆者の開発環境は、下記のとおりです。

$ sw_vers 
ProductName:	macOS
ProductVersion:	11.4
BuildVersion:	20F71
$ node -v
v14.11.0
$ npm -v
6.14.8

前回の記事に引き続き、Content.js をテキストエディターで編集していきます。getStepContent 関数は、React が管理している Stepper コンポーネントのインデックス番号(activeStep)に応じたコンテンツを取得する処理を記述しています。そのため、各インデックス番号に応じたコンテンツ(コンポーネント)を下記のように返してあげれば良さそうです。

Content.js

:
snip
:
import Basic from "./Basic";
import Optional from "./Optional";
import Confirm from "./Confirm";

function getSteps() {
    return [
        '基本項目',
        '任意項目',
        '入力確認'
    ];
}

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

Content.js を編集後、各コンテンツのコンポーネントに応じたソースファイルを、用意します。

$ touch src/components/Basic.js
$ touch src/components/Optional.js
$ touch src/components/Confirm.js

次に各コンポーネントの実装を見ていきます。

まずは Basic.js です。基本項目として、3つのアイテム(チェックボックス、テキストフィールド、プルダウンリスト)を用意してみました。react-hook-form v7 と Material-UI を組み合わせた利用方法については、React Hook Form の公式ドキュメントにサンプルコード付きで掲載されておりますので、ご確認ください。

少し長くなりますが、全文ソースコードを転記します。

Basic.js

import { Grid } from '@material-ui/core'
import { useForm, Controller } from "react-hook-form";
import TextField from "@material-ui/core/TextField";
import { Button, MenuItem } from "@material-ui/core";
import FormControlLabel from '@material-ui/core/FormControlLabel';
import Checkbox from '@material-ui/core/Checkbox';

function Basic(props) {
    const { control, handleSubmit } = useForm({
        defaultValues: {
            checkBox: false,
            textBox: "",
            pullDown: "",
        },
    });
    const onSubmit = () => {
        props.handleNext();
    };
    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 } }) => (
                            <FormControlLabel
                                control={
                                    <Checkbox
                                        checked={value}
                                        onChange={onChange}
                                        color='primary'
                                    />
                                }
                                label="チェックボックス"
                            />
                        )}
                    />
                    <Controller
                        control={control}
                        name="textBox"
                        render={({ field }) => (
                            <TextField
                                {...field}
                                label="テキストフィールド"
                                fullWidth
                                margin="normal"
                                placeholder="プレースホルダー"
                            />
                        )}
                    />
                    <Controller
                        control={control}
                        name="pullDown"
                        render={({ field }) => (
                            <TextField
                                {...field}
                                label="プルダウンリスト"
                                fullWidth
                                margin="normal"
                                id="select"
                                select
                            >
                                <MenuItem value="one">選択肢1</MenuItem>
                                <MenuItem value="two">選択肢2</MenuItem>
                                <MenuItem value="three">選択肢3</MenuItem>
                            </TextField>
                        )}
                    />
                    <Button
                        variant="contained"
                        color="primary"
                        type="submit"
                    >
                        次へ
                    </Button>
                </form>
            </Grid>
        </Grid>
    )
}

export default Basic

ブラウザで、確認してみるとかなり Web フォームらしくなってきました。

1-6-640x474.png

テキストフィールドにマウスカーソルを合わせて、入力待ちの状態になるとプレースホルダーが表示されます。

2-7-640x117.png

また、プルダウンリストを選択すると選択項目が表示されます。

3-7-640x247.png

いずれも、Material-UI が提供している部品を活用しています。

この Web フォーム(React アプリ)のポイントは、下記の3点です。

  • フォーム(項目)への入力や入力内容は、react-hook-form が管理してくれる
  • フォームに配置された各アイテムのデザインや入力体験は、Material-UI が面倒を見てくれる
  • 基本項目フォームの「次へ」ボタンは、Content.js に定義された handleNext() 関数をプロパティ経由で受け取って呼び出すことで、次のフォームへ遷移させている(Stepper コンポーネントのインデックス番号を1つ繰り上げている)

Basic.js の次は、Optional.js です。任意項目として、複数行の記述が可能なテキストフィールドを用意してみました。

Optional.js

import { Grid } from '@material-ui/core'
import { useForm, Controller } from "react-hook-form";
import TextField from "@material-ui/core/TextField";
import { Button } from "@material-ui/core";
import Tooltip from '@material-ui/core/Tooltip';

function Optional(props) {
    const { control, handleSubmit } = useForm({
        defaultValues: {
            multilineText: "",
        },
    });
    const onSubmit = (action) => {
        if(action === 'back') {
            props.handleBack();
        } else if (action === 'next') {
            props.handleNext();
        }
    };
    return (
        <Grid container>
            <Grid sm={2}/>
            <Grid lg={8} sm={8} spacing={10}>
                <form onSubmit={handleSubmit(onSubmit)}>
                    <Controller
                        control={control}
                        name="multilineText"
                        render={({ field }) => (
                            <Tooltip
                                title="自由に記入することができます"
                                placement="top-start"
                                arrow
                            >
                                <TextField
                                    {...field}
                                    label="備考欄"
                                    fullWidth
                                    margin="normal"
                                    rows={4}
                                    multiline
                                    variant="outlined"
                                    placeholder="その他ご要望等あれば、ご記入ください"
                                />
                            </Tooltip>
                        )}
                    />
                    <Button
                        variant="contained"
                        color="primary"
                        onClick={() => onSubmit("back")}
                    >
                        戻る
                    </Button>
                    <Button
                        variant="contained"
                        color="primary"
                        onClick={() => onSubmit("next")}
                    >
                        次へ
                    </Button>
                </form>
            </Grid>
        </Grid>
    )
}

export default Optional

ブラウザで、確認してみます。

4-7-640x474.png

Optional.js では、Tooltip を追加しています。

5-5.png

また、Basic.js とは異なり、「戻る」ボタンと「次へ」ボタンが表示されています。各ボタンの onClick イベントで呼ばれる onSubmit() 関数に "back" や "next" といったアクション(プロパティ)を渡しており、onSubmit() 関数内で、各アクションに応じた処理(インデックス番号の増減)を行っています。

各ステップ毎の入力情報を、保存したり取得したりする

ここまでの実装で基本項目や任意項目のフォーム(コンテンツ)は用意できましたが、各フォームに入力された入力情報は各コンポーネントの react-hook-form がステートを保持したままの状態であり各ステップ毎の入力情報は、どこかに保存して管理しておく必要があります。そこで、React のコンテクストを利用して各コンポーネントのデータを管理してみました。

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

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 も同様の改修を行います。

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 を利用して確認してみます。

基本項目フォームに入力した状態です。

6-6-640x320.png

画像右側は、React Developer Tools で Content コンポーネントのステートを表示していますが、まだボタンはクリックしていないため、ステートは空({})の状態です。「次へ」ボタンをクリックします。

7-5-640x320.png

ブラウザの画面上は任意項目に表示が遷移しており、Content コンポーネントのステートとしてフォームで入力したデータが保持されています。また、インデックス番号が 0 → 1 に変化していることが確認できました。

本記事の仕上げとして、Confirm.js を用意しましょう。

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

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

8-5-640x474.png

ポイントは、以下のとおりです。

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

現状の実装では、送信ボタンをクリックしても各フォームで入力した情報を JSON 形式でアラートとして表示するのみとなっています。

9-4-640x474.png

随分、Web フォームらしくなりましたね。

さいごに

本記事にて、基本項目のフォームと任意項目のフォームおよび入力確認画面を実装しました。一般的な Web フォームですと、入力項目を必須入力にしたり、入力されるデータをチェックして期待した値が入力されていない場合にエラーメッセージを表示させるなどのバリデーション機能が実装されていると思います。現時点で、まだバリデーション機能を実装していないため次回はバリデーション機能を追加し Web フォームを完成させていきたいと思います。

ではでは

参考情報