ご意見送信フォームを作成する② [React]

概要

この記事について

かんたんな概要と結論

ReactとReact関連ライブラリを用いて意見送信フォームを作成した。
特に、React-Hook-Formを用いるとバリデーション周りがかなり簡単に実装できた。

先日作成した意見送信フォームを、
Reactを用いてリファクタした。

その際に、React-Hook-Formというフォーム作成用ライブラリを用いると
短時間で高機能なフォームが作成できるという話を見たため、
試してみることにした。

ソースコードはこちらにあります(GitHub)。

設計

クラス設計

About

Form用のComponentとModal用のComponentを用意する。
上位のComponentで状態を管理し、表示/非表示を切り替える。

クラス図

クラス図

実装

作成環境

1node v14.16.1
2npm 6.14.12
3create-react-app

依存パッケージ

一部抜粋。

 1{
 2    "dependencies":{
 3        "react": "^17.0.2",
 4    "react-dom": "^17.0.2",
 5    "react-scripts": "4.0.3",
 6    "typescript": "^4.4.3",
 7    "moment": "^2.29.1",
 8    "node-sass": "^6.0.1",
 9    "react-hook-form": "^7.16.1"
10   }
11}
12

ディレクトリ構成

一部抜粋。

 1my-app
 2│  package-lock.json
 3│  package.json
 4│  tsconfig.json
 5 6├─node_modules
 7 8├─public
 9│      index.html
1011└─src
12    │  index.css
13    │  index.tsx
14    │  react-app-env.d.ts
15    │  setupTests.ts
1617    ├─style
18    │      style.scss
19    │      _active.scss
20    │      _form.scss
21    │      _mixin.scss
22    │      _modal.scss
23    │      _variables.scss
2425    └─ts
26        │  App.test.tsx
27        │  App.tsx
2829        ├─form
30        │      opinion-form.test.tsx
31        │      opinion-form.tsx
3233        └─modal
34                modalizer.test.tsx
35                modalizer.tsx
36                submit-indicator.test.tsx
37                submit-indicator.tsx

コード

ソースコードの全体はこちらから

OpinionForm Component

  1interface OpinionFormArgs {
  2    onSubmit: (data: IFormInput) => void;
  3}
  4
  5export interface IFormInput {
  6    fname: string;
  7    fgender: string;
  8    fage: string;
  9    faddress: string;
 10    fmessage: string;
 11}
 12export function OpinionForm({ onSubmit }: OpinionFormArgs) {
 13    const {
 14        register,
 15        handleSubmit,
 16        formState: { errors, isSubmitSuccessful, submitCount },
 17        reset,
 18    } = useForm<IFormInput>({
 19        criteriaMode: "all",
 20        defaultValues: {
 21            fgender: "0",
 22        },
 23    });
 24
 25    // submit時、上位Componentに処理を委譲
 26    const onFormSubmit = (data: IFormInput) => {
 27        onSubmit(data);
 28    };
 29
 30    // input要素がアクティブになった際にラベルにクラスを付与するイベント
 31    const onInputFocus: FocusEventHandler<
 32        HTMLInputElement | HTMLTextAreaElement
 33    > = (ev) => {
 34        document
 35            .querySelector(`label[for="${ev.currentTarget?.id}"]`)
 36            ?.classList.add("active");
 37    };
 38
 39    const onInputBlur: FocusEventHandler<
 40        HTMLInputElement | HTMLTextAreaElement
 41    > = (ev) => {
 42        if (ev.currentTarget.value === "") {
 43            document
 44                .querySelector(`label[for="${ev.currentTarget?.id}"]`)
 45                ?.classList.remove("active");
 46        }
 47    };
 48
 49    useEffect(() => {
 50        // submit後、入力欄をリセットする
 51        if (isSubmitSuccessful) {
 52            reset();
 53        }
 54    }, [submitCount, reset, isSubmitSuccessful]);
 55
 56    return (
 57        <form
 58            id="opinion-send"
 59            name="opinion-send"
 60            autoComplete="on"
 61            onSubmit={handleSubmit(onFormSubmit)}
 62        >
 63            <h2>
 64                <span>ご意見フォーム</span>
 65            </h2>
 66            <div className="controls">
 67                <input
 68                    id="fname"
 69                    type="text"
 70                    {...register("fname", {
 71                        required: "名前は必須です。",
 72                        maxLength: {
 73                            value: 20,
 74                            message: "名前は120文字で入力してください。",
 75                        },
 76                    })}
 77                    onFocus={onInputFocus}
 78                    onBlur={onInputBlur}
 79                />
 80                <label htmlFor="fname">ご氏名: </label>
 81                {errors?.fname && <p role="alert">{errors.fname.message}</p>}
 82            </div>
 83            <div className="controls">
 84                <fieldset>
 85                    <legend>ご性別</legend>
 86                    <input
 87                        type="radio"
 88                        id="fgender-other"
 89                        {...register("fgender", { required: true })}
 90                        value="0"
 91                    />
 92                    <label htmlFor="fgender-other">その他</label>
 93                    <input
 94                        type="radio"
 95                        id="fgender-man"
 96                        {...register("fgender", { required: true })}
 97                        value="1"
 98                    />
 99                    <label htmlFor="fgender-man">男性</label>
100                    <input
101                        type="radio"
102                        id="fgender-woman"
103                        {...register("fgender", { required: true })}
104                        value="2"
105                    />
106                    <label htmlFor="fgender-woman">女性</label>
107                </fieldset>
108            </div>
109            <div className="controls">
110                <input
111                    id="fage"
112                    type="number"
113                    {...register("fage", {
114                        required: "年齢は必須です。",
115                        min: {
116                            value: 0,
117                            message: "年齢を正しく入力してください。",
118                        },
119                        pattern: {
120                            value: /[0-9]+/,
121                            message:
122                                "年齢を正しく入力してください数字のみ)。",
123                        },
124                    })}
125                    onFocus={onInputFocus}
126                    onBlur={onInputBlur}
127                />
128                <label htmlFor="fage">ご年齢:</label>
129                {errors?.fage && <p role="alert">{errors.fage.message}</p>}
130            </div>
131            <div className="controls">
132                <input
133                    type="text"
134                    id="faddress"
135                    {...register("faddress", {
136                        required: "住所は必須です。",
137                        maxLength: {
138                            value: 100,
139                            message: "住所は100文字以内で入力してください。",
140                        },
141                    })}
142                    onFocus={onInputFocus}
143                    onBlur={onInputBlur}
144                />
145                <label htmlFor="faddress">ご住所:</label>
146                {errors?.faddress && (
147                    <p role="alert">{errors.faddress.message}</p>
148                )}
149            </div>
150            <div className="controls">
151                <textarea
152                    id="fmessage"
153                    {...register("fmessage", {
154                        required: "ご意見を入力してください。",
155                        maxLength: {
156                            value: 2000,
157                            message: "ご意見は12000文字で入力してください。",
158                        },
159                    })}
160                    onFocus={onInputFocus}
161                    onBlur={onInputBlur}
162                />
163                <label htmlFor="fmessage">ご意見:</label>
164                {errors?.fmessage && (
165                    <p role="alert">{errors.fmessage.message}</p>
166                )}
167            </div>
168            <br />
169            <br />
170            <div className="controls gapped-inlines">
171                <input
172                    type="submit"
173                    id="fbutton"
174                    name="fbutton"
175                    value="Submit"
176                />
177                <input type="button" value="Reset" onClick={() => reset()} />
178            </div>
179        </form>
180    );
181}

React-Hook-Formを利用している。

各入力欄のバリデーションや、エラー時の表示メッセージを宣言的に書くことができる。 また、バリデーションなどに関わるコードの量も削減できている。

 1<input
 2    id="fname"
 3    type="text"
 4    {...register("fname", {
 5        required: "名前は必須です。",
 6        maxLength: {
 7            value: 20,
 8            message: "名前は120文字で入力してください。",
 9        },
10    })}
11/>

SubmitIndicator Component

  1export interface accepttedContentData {
  2    name: string;
  3    gender: string;
  4    age: string;
  5    address: string;
  6    message: string;
  7}
  8
  9interface ISubmitIndicatorArgs {
 10    formValues: accepttedContentData;
 11    onCloseModal: () => void;
 12}
 13
 14const textTemplate = `※ご意見フォーム送信フェイク※
 15下記内容で承りました。
 16
 17【氏名】$name
 18【性別】$gender
 19【年齢】$age歳
 20【住所】$address
 21【ご意見内容】
 22$message
 23
 24-----------------------
 25
 26受理日時:$date
 27
 28`;
 29
 30// 渡された受理内容のテキストを作成する
 31export function createTextForAccepttedContent(data: accepttedContentData) {
 32
 33    // convert gender:int to string
 34    data.gender = ["その他", "男性", "女性"][Number(data.gender)];
 35
 36    let text = textTemplate;
 37    let key: keyof accepttedContentData;
 38    for (key in data) {
 39        if (Object.hasOwnProperty.call(data, key)) {
 40            text = text.replace("$" + key, data[key]);
 41        }
 42    }
 43    text = text.replace("$date", moment().format("YYYY年MM月DD日"));
 44
 45    return text;
 46}
 47
 48export function createTextBlob(text: string) {
 49    const blob = new Blob([text], { type: "text/plain" });
 50    return URL.createObjectURL(blob);
 51}
 52
 53export function SubmitIndicator({
 54    formValues,
 55    onCloseModal,
 56}: ISubmitIndicatorArgs) {
 57    const ref = useRef<HTMLButtonElement>(null);
 58
 59    useEffect(() => {
 60        setTimeout(() => {
 61            ref.current?.focus();
 62        }, 0);
 63        document.body.classList.add("preventScroll");
 64        return () => {
 65            document.body.classList.remove("preventScroll");
 66        };
 67    }, []);
 68
 69
 70    const onClickDownload = () => {
 71        const text = createTextForAccepttedContent(formValues);
 72        const link = document.createElement("a");
 73        link.download = "受理内容.txt";
 74        link.href = createTextBlob(text);
 75
 76        link.click();
 77
 78        URL.revokeObjectURL(link.href);
 79    };
 80
 81    // Closeボタン押下イベントを上位Componentに移譲
 82    const onClickClose = () => {
 83        onCloseModal();
 84    };
 85
 86    return (
 87        <>
 88            <div id="modal-container">
 89                <div id="modal-box">
 90                    <div id="modal-message">ご意見を受け付けました。</div>
 91                    <button
 92                        id="modal-download"
 93                        onClick={onClickDownload}
 94                        ref={ref}
 95                    >
 96                        送信内容のダウンロード
 97                    </button>
 98                    <div
 99                        id="modal-close"
100                        tabIndex={0}
101                        role="button"
102                        aria-label="閉じる"
103                        onClick={onClickClose}
104                    >
105                        
106                    </div>
107                </div>
108            </div>
109            <div id="cover-div"></div>
110        </>
111    );
112}

モーダルポップアップのDOMとイベントを管理するComponent。

App Component

 1function App() {
 2    const [isShowModal, setIsShowModal] = useState(false);
 3    const [formValues, setFormValues] = useState<accepttedContentData>(
 4        {} as accepttedContentData
 5    );
 6    const [activeElementStocked, setActiveElementStocked] =
 7        useState<HTMLElement | null>(null);
 8
 9    const modalContainer = document.getElementById("modal-root");
10
11    // submit時実行メソッド
12    const onSubmit = useCallback(
13        (data: IFormInput) => {
14            setFormValues({
15                name: data.fname,
16                gender: data.fgender,
17                age: data.fage,
18                address: data.faddress,
19                message: data.fmessage,
20            });
21
22            setIsShowModal(true);
23            if (document.activeElement instanceof HTMLElement) {
24                setActiveElementStocked(document.activeElement);
25            }
26        },
27        [setIsShowModal]
28    );
29
30    // モーダルのClose時実行メソッド
31    const onCloseModal = useCallback(() => {
32        setIsShowModal(false);
33        activeElementStocked?.focus();
34    }, [setIsShowModal, activeElementStocked]);
35
36    return (
37        <>
38            <div id="temp-page-top" style={{ marginBottom: "50px" }}>
39                {" "}
40            </div>
41            <div className="normal-form-container">
42                <OpinionForm onSubmit={onSubmit}></OpinionForm>
43            </div>
44            // Submitボタンが押下された場合にのみ表示する要素
45            {isShowModal && modalContainer && (
46                <Modalizer modalContainer={modalContainer}>
47                    <SubmitIndicator
48                        formValues={formValues}
49                        onCloseModal={onCloseModal}
50                    />
51                </Modalizer>
52            )}
53        </>
54    );
55}

デモ(Vercel)

Vercelにデプロイしました。

終わりに

React-Hook-Formは非常に強力なフォーム作成ライブラリで、
たいがいのバリデーション処理はすでに定義されている機能をあてはめることで実装できる。

JSXの構造を見るだけで、どのようなバリデーションが行われるか分かるのはとても理解がしやすいかと思う。

関連記事

comments powered by Disqus