ご意見送信フォームを作成する② [React]
概要
この記事について
かんたんな概要と結論
ReactとReact関連ライブラリを用いて意見送信フォームを作成した。
特に、React-Hook-Formを用いるとバリデーション周りがかなり簡単に実装できた。
特に、React-Hook-Formを用いるとバリデーション周りがかなり簡単に実装できた。
先日作成した意見送信フォームを、
Reactを用いてリファクタした。
その際に、React-Hook-Formというフォーム作成用ライブラリを用いると
短時間で高機能なフォームが作成できるという話を見たため、
試してみることにした。
設計
クラス設計
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
10│
11└─src
12 │ index.css
13 │ index.tsx
14 │ react-app-env.d.ts
15 │ setupTests.ts
16 │
17 ├─style
18 │ style.scss
19 │ _active.scss
20 │ _form.scss
21 │ _mixin.scss
22 │ _modal.scss
23 │ _variables.scss
24 │
25 └─ts
26 │ App.test.tsx
27 │ App.tsx
28 │
29 ├─form
30 │ opinion-form.test.tsx
31 │ opinion-form.tsx
32 │
33 └─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: "名前は1~20文字で入力してください。",
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: "ご意見は1~2000文字で入力してください。",
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: "名前は1~20文字で入力してください。",
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)
終わりに
React-Hook-Formは非常に強力なフォーム作成ライブラリで、
たいがいのバリデーション処理はすでに定義されている機能をあてはめることで実装できる。
JSXの構造を見るだけで、どのようなバリデーションが行われるか分かるのはとても理解がしやすいかと思う。