Edited at

初心者による初心者のためのReact+TypeScriptで作るお問い合わせフォーム


こんなの作りました。


はじめに

この記事は、TypeScriptを勉強し始めて、何か作ってみたいなと思っている方(まさに私)を対象にしていますので、勉強のための開発環境構築から、コンポーネントへの分割、機能実装という順で記述しています。

順を追って開発していくと、上で作ったものが完成していきます。(※スタイルは省略しています。)


開発環境整備

勉強のために簡単に環境を整えたかったので、Create Next App を使うことにしました。(挑戦)

Create Next App を使うとSPAの雛形を簡単に用意することができます。

$ npm i -g create-next-app

$ create-next-app --example with-typescript xxx

xxxはprojectの名前を設定します。が、もし xxx 省略して入力すると以下のように、プロジェクト名は何にしますか?というような質問がなされます。そこで答えることも可能です。

? What is your project named? > my-ts-project

今回私は、my-ts-projectというプロジェクトを作成しました。

Create Next Appは親切ですね。

次に実行するコマンドを教えてくれました。

プロジェクトのフォルダへ移動してyarn devを実行すると、ビルドが始まって一瞬で完了します。

このような画面が表示されるとOKです。


ContactForm.tsx

import React, { FC } from 'react';

const ContactForm: FC = () => (
<>
<h2>お問い合わせフォーム</h2>
</>
);

export default ContactForm;


上記で作成したContactFormコンポーネントをindex.tsxへ読み込みました。


index.tsx

import * as React from 'react'

import ContactForm from '../components/container/ContactForm'
import { NextPage } from 'next'

const IndexPage: NextPage = () => {
return (
<ContactForm></ContactForm>
)
}

export default IndexPage


これで出来上がったお問い合わせコンポーネントを表示する準備もできました。それでは、実装に入っていきます!


コンポーネント作る

お問い合わせコンポーネントとしてまるっと実装しても良いのですが、より小さいコンポーネントに分けられる箇所がありそうなのでコンポーネントを分けます。

今回は、4つのコンポーネントを作って行きます。


  1. 「お問い合わせ項目」などのテキストと「必須ボタン」をセットにしたコンポーネント



  2. <textarea>タグを自作コンポーネントにする


  3. <input>タグを自作コンポーネントにする


  4. ラジオボタンをラップしたコンポーネント



今回は、コンポーネントにしていない見た目のみのコーディングをしたのちに、どの部分を小さなコンポーネントとして分割するかを決めてから実装スタートという流れで行きたいと思います。

見た目のみの実装は下記のようになりました。


ContactForm.tsx


import React, { FC } from 'react';

const ContactForm: FC = () => (
<>
<h2>お問い合わせフォーム</h2>
<form>
<div>
<div>
<p>お問い合わせ項目</p>
<p>必須</p>
</div>
<div>
<input type="radio" name="aradio" value="A" />
<label>商品について</label>
<input type="radio" name="bradio" value="B" />
<label>採用について</label>
<input type="radio" name="cradio" value="C" />
<label>その他</label>
</div>
</div>
<hr></hr>

<div>
<div>
<p>お問い合わせ内容</p>
<p>必須</p>
</div>
<p><textarea></textarea></p>
</div>
<hr></hr>

<div>
<div>
<p>お名前</p>
<p>必須</p>
</div>
<div>
<p><input type="text" /></p><p><input type="text" /></p>
</div>
</div>
<hr></hr>
<div>
<div>
<p>フリガナ</p>
<p>必須</p>
</div>
<div>
<p>セイ<input type="text" /></p><p>メイ<input type="text" /></p>
</div>
</div>
<hr></hr>
<div>
<div>
<div>
<p>メールアドレス</p>
<p>必須</p>
</div>
</div>
<p><input type="text" /></p>
</div>
<hr></hr>

<div>
<div>
<p>返信不要欄</p>
</div>
<div>
<input type="checkbox" /><p>返信不要 <span>※返信が不要な場合はチェックしてください)</span></p>
</div>
</div>
</form>
</>
);

export default ContactForm;


このままだと、TypeScriptを勉強するコードではありませんし、いかにも工夫さなれてないコード感満載です。

ではこれから、React+TypeScriptでコードを変えていきます。

まずはこのコンポーネントから。

この部分を、5つ作ることになるのでコンポーネントに分けて量産できるようにします。

下記のようにFormItemコンポーネントに切り分けました。

量産できるようにするにはどういう構造にするのがいいのか?ということを考えなくてはいけません。今回は下記のような構造にしました。

ContactFormコンポーネントを親とする

・お問い合わせ項目やお名前などのtitle要素をpropsとして渡す

・必須バッヂを表示or非表示の判別をするrequired要素をpropsとして渡す

この構造になるように実装すると以下のようになりました。


FormItem.tsx

import React, { FC } from 'react';

interface FormListProps {
title: string;
required: boolean;
}

const FormItem: FC<FormListProps> = props => {
const { title, required } = props;
return (
<>
<p>{ title }</p>
{
required && <p>必須</p>
}
</>
);
};

export default FormItem;


ContactForm.tsxには、<p>タグで記述していたものを<FormItem>タグでかけるようになったので書き換えて、FormItemコンポーネントで必要な要素を渡してあげるようにします。

- <p>お問い合わせ項目</p>

- <p>必須</p>
+ <FormItem title='お問い合わせ項目' required={true}></FormItem>

続いて、2つ目のコンポーネントを実装していきます。


<textarea>タグをコンポーネントにする


TextareaComp.tsx

import React, { FC } from 'react';

interface Props {
name: string,
value: string
}

const TextareaComp: FC<Props> = props => {
const { value } = props;
return (
<>
<textarea value={value} ></textarea>
</>
);
};

export default TextareaComp;


- <textarea></textarea>

+ <TextareaComp name="お問い合わせ内容" value={state.comment} />

<textarea>タグは、使い回すかもしれないということで基本的にはコンポーネントに分けてしまいます。が、今回は一回しか利用しませんのでそこまでコンポーネントの恩恵を感じることはありませんね...

入力系のコンポーネントは、入力値であるvalue属性をpropsとして渡すように実装しました。次に実装する<input>タグのコンポーネントも<TextareaComp>コンポーネントと同じ構造で作りました。


<input>タグをコンポーネントにする

ContactFormコンポーネントに、<input>タグで記述していた以下の箇所を、

- <input type="text" />

+ <InputComp name="last_name" value={state.last_name} />

InputCompコンポーネントとして切り出しました。

ContactForm.tsxファイルには、importを忘れないようにしましょう。


InputComp.tsx

import React, { FC } from 'react';

interface Props {
name: string,
value: string
}

const InputComp: FC<Props> = props => {
const { value } = props;
return (
<>
<input type="text" value={value} />
</>
);
};

export default InputComp;


2つの入力系のコンポーネントを作成しました。

次は、この2つのフォーム要素に、入力内容更新/削除するstate機能を付与していくのですが、まずは入力内容が更新された時に発生するonChangeイベントを指定していきます。


入力した内容の保持・更新を行う機能を実装- React Hooks編-


onChangeハンドラを指定する

フォームに入力した内容が更新されたり、削除されたりと行った時に処理が行えるようにできるものが、onChangeハンドラです。自作した<TextareaComp>タグにonChangeイベントハンドラを指定し、TextareaCompのstateであるcommentが更新された時、onChangeHandler関数が発火するようにしました。

- <TextareaComp name="お問い合わせ内容" value={state.comment} />

+ <TextareaComp name="お問い合わせ内容" value={state.comment} onChange={onChangeHandler}/>


ContactForm.tsx


const onChangeHandler = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
e.persist();
setState((prevState) => {
return {...prevState, comment: e.target.value };
});
}

onChangeHandler関数は、引数にe(event)を持たせ、その型を引数内で記載しています。

eの型とは?何を指定すればいいの?とわからない時はeをhoverしてみましょう。するとこのように、

この時、eに指定できる型は、これこれですよというふうにVSCodeがヒントをくれています。

次に、2行目に書かれてあるe.persist();についてです。

もし、この記述を抜かして実行してみます。そして、<textarea>の値を更新する(試しに1文字入力してみる)と、このようなエラーが出てしまいました。

このエラーが出る原因は「ReactのsetStateは非同期処理によってstateを書き換えるから」ということなのですが...詳細を解説していきます。

JavaScriptでは、上から順に処理が実行されていきます。しかし、stateをsetStateで更新する処理は非同期処理のため、setStateの処理が終わるのを待たずに次の処理を実行します。(厳密に理解したい方はこちらを参考にしてください1)

ですが、e(event)オブジェクトは、SyntheticEvent(合成イベント)オブジェクトとしてラップされているため、使いまわしされます。そためで処理全て(処理①,②,setState,④)が実行され終わると(非同期処理中のsetStateの処理が終わるのを待たずに)全てのプロパティをnullにしてしまいます。(上のエラー文にもnullになっていますというエラーが出ていました。)

したがって、非同期では、eにアクセスすることはできないのです。

そのため、非同期でアクセスしたいのならばevent.persist()を使ってください、とReactのドキュメント2 に書かれていました。

persistの日本語の意味は持続する。つまりeオブジェクトを持続させますという宣言をしないといけないようです。

eの持続を宣言したところで、次はsetStateでcommentのstateを更新しましょう。

onChangeHandler内のsetStateの記述は以下のようになっています。

return {...prevState,  comment: e.target.value };

この時、preveStateは、{comment: '',last_name: ''}という2つのプロパティを持つオブジェクトです。スプレッド構文を使うと、このオブジェクトを一度「展開」し、第二引数で指定したプロパティをmergeし、新しいpreveStateオブジェクトを生成します。

スプレッド構文についての詳細は詳しい記事3をご覧ください。

次は、自作したTextareaCompに対して、onChangeイベントハンドラを渡してあげましょう。下記のようにTextareaCompコンポーネントファイルで、propsとして受け取ります。


interface Props {
name: string,
value: string,
+ onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
}

TypeScriptでは欠かさず、型の指定をしてあげないといけません。

渡されたocChangeの型は、引数を受け取っていますが、何かをreturnする関数ではないので、arrow関数で「引数はeで、返り値はvoidの関数型」という型を指定しました。

この要領で、<input>タグについてもonChangeハンドラを設定していきます。

本来、Hooksを利用してstateの保持とそのstateの変更はsetState(名前はなんでもいい)を使います。このように、

const [ comment, setComment ] = useState('商品名やお問い合わせ内容を入力ください');・・・()

お問い合わせ内容を入力する箇所のstateの保持とその値を更新するsetCommentを準備します。しかし、今回は、stateとして保持したいものが多いため(姓・名、フリガナ姓・名、メールアドレスの5つ)、1つにつき1つ(☆)を用意していると5つも用意しないといけません。(ラジオボタンのinputは別のコンポーネントにする)

5つ用意するのではなく、1つのstateオブジェクトで複数の値を保持して、それぞれの値を更新する方法4,5で実装していきたいと思います。commentにのみ初期値を設定しました。そのほかの5つは初期値は不要なので''空文字を指定しました。


ContactForm.tsx

const [ state, setState ] = useState({

comment: '商品名やお問い合わせ内容を入力ください',
last_name: '',
first_name: '',
last_name_kana: '',
first_name_kana: '',
email: ''
});

// お問い合わせ内容、氏名・氏名(フリガナ)・メールアドレスに関するonChangeハンドラ・・・(1)
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.persist();
const target = e.target;
const name = target.name;
setState(() => {
return {...state, [name]: target.value };
});
}


import React, { FC } from 'react';


interface Props {
name: string,
value: string,
+ onChange: (event: React.ChangeEvent<HTMLInputElement>) => void
}

const InputComp: FC<Props> = props => {
- const { value } = props;
+ const { value, onChange, name } = props;
return (
<>
- <input type="text" value={value} />
+ <input type="text" name={name} value={value} onChange={e => onChange(e)} />
</>
);
};

export default InputComp;

次にこのコンポーネントを再利用しやすいような形に実装していきます。

ContactForm.tsxファイルに以下を追加します。

+ const checkList = ["商品について","採用について","その他"];

そして、DOM部分を以下のように書き換えます。

- <div>

- <input type="radio" name="aradio" value="A" />
- <label>商品について</label>
- <input type="radio" name="bradio" value="B" />
- <label>採用について</label>
- <input type="radio" name="cradio" value="C" />
- <label>その他</label>
- </div>
+ <ChackboxContainer name="about" value={checkList}/>


ChackboxContainer.tsx

import React, { FC } from 'react';

interface Props {
name: string,
value: Array<string>,
}

const ChackboxContainer: FC<Props> = props => {
const { name, value } = props;
return (
<>
{ value.map((item, index) =>
<label key={index}>
<input type="radio" name={name} />{ item }
</label>)}
</>
);
};

export default ChackboxContainer;


作成したChackboxContainerコンポーネントのimportも忘れずに行いましょう。

これでonChageハンドラについてはOKです。

次に、コンポーネントに対して入力した値を削除する機能を作っていきます。

これから、入力内容をクリアボタンに対してイベントハンドラを追加していきます。

まずは、入力内容をクリアするボタンを用意しました。簡素ですがこれでいいでしょう。

</form>

+ <div>
+  <button onClick={clearAllInputValue}>入力内容をクリア</button>
+ </div>

次に、入力した内容をクリアする処理を実装します。

setStateを使って、それぞれの値に''空の文字列を渡すように描いてあげると実装できます。


const clearAllInputValue = () => {
setState({
comment: '',
last_name: '',
first_name: '',
last_name_kana: '',
first_name_kana: '',
email: '',
about_item: '',
about_recruit: '',
about_others: '',
reply: ''
});
};

ほとんどの機能が実装し終えました。

最後に、[送信]ボタンを実装していきます。

- <button>送信</button>

+ <button onClick={submitAlert}>送信</button>

ボタンにonClickイベントを付与し、未入力の項目がないかチェックをして、必須項目に入力がなされていないと、「未入力項目があります」とアラートが表示され、入力内容に問題がないと[送信します]とアラートが表示されるように実装します。


ContactForm.tsx

// 送信ボタンを押下したら発火

const submitAlert = (e: React.MouseEvent) => {
e.persist();
e.preventDefault();
const error = Object.values(state).some((value) => {
return value.length === 0;
});

if(error) {
alert('未入力項目があります');
} else {
alert('送信します');
}


これにて、お問い合わせフォームコンポーネントの実装において挑戦したかったことは全て終了しました。


おわりに

今回はお問い合わせコンポーネントを実装しました。これぐらい小規模なコンポーネントの実装に、沢山の知識を要し、非常に長い記事になってしまいました。大規模な開発において、TypeScriptの型宣言には苦しめられるのではないかな?と想像しましたが、開発が大きくなれば大きくなるだけ、型の宣言のおかげで助けられることもあるのでしょうとも思います。

今回の記事では、とにかく実務に近いレベル感での実装を目指し、コンポーネントの実装やイベントハンドラの設定に挑戦しましたが、実務においてはまだまだバリデーションやコンポーネントの再利用性を考えた実装を考えないといけないといけません。それは今後の課題としてこの記事はこれにて終了しようと思います。最後まで読んでいただきありがとうございました。


ContactForm.tsxの全貌


ContactForm.tsx


import React, { FC, useState } from 'react';
import FormItem from '../parts/FormItem';
import TextareaComp from '../parts/TextareaComp';
import InputComp from '../parts/InputComp';
import ChackboxContainer from '../parts/ChackboxContainer';

const ContactForm: FC = () => {
const [ state, setState ] = useState({
comment: '商品名やお問い合わせ内容を入力ください',
last_name: '',
first_name: '',
last_name_kana: '',
first_name_kana: '',
email: '',
});

const clearAllInputValue = () => {
setState({
comment: '',
last_name: '',
first_name: '',
last_name_kana: '',
first_name_kana: '',
email: ''
});
};

// お問い合わせ項目のリストを作成
const checkList = ["商品について","採用について","その他"];

// お問い合わせ内容に関するonChangeハンドラ
const onChangeHandler = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
e.persist();
setState((prevState) => {
return {...prevState, comment: e.target.value };
});
}

// お問い合わせ内容、氏名・氏名(フリガナ)・メールアドレスに関するonChangeハンドラ
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.persist();
const target = e.target;
const name = target.name;
setState(() => {
return {...state, [name]: target.value };
});
}

// 送信ボタンを押下したら発火
const submitAlert = (e: React.MouseEvent) => {
e.persist();
e.preventDefault();
const error = Object.values(state).some((value) => {
return value.length === 0;
});

if(error) {
alert('未入力項目があります');
} else {
alert('送信します');
}

};

return (
<>
<h2>お問い合わせフォーム</h2>
<form>
<div>
<div>
<FormItem title='お問い合わせ項目' required={true}></FormItem>
</div>
<ChackboxContainer name="about" value={checkList}/>
</div>
<hr></hr>
<div>
<div>
<FormItem title='お問い合わせ内容' required={true}></FormItem>
</div>
<TextareaComp name="お問い合わせ内容" value={state.comment} onChange={onChangeHandler}/>
</div>
<hr></hr>
<div>
<div>
<FormItem title='お名前' required={true}></FormItem>
</div>
<div>
<p><InputComp name="last_name" value={state.last_name} onChange={handleInputChange}/></p>
<p><InputComp name="first_name" value={state.first_name} onChange={handleInputChange}/></p>
</div>
</div>
<hr></hr>
<div>
<div>
<FormItem title='フリガナ' required={true}></FormItem>
</div>
<div>
<p>セイ<InputComp name="last_name_kana" value={state.last_name_kana} onChange={handleInputChange}/></p>
<p>メイ<InputComp name="first_name_kana" value={state.first_name_kana} onChange={handleInputChange}/></p>
</div>
</div>
<hr></hr>
<div>
<div>
<div>
<FormItem title='メールアドレス' required={true}></FormItem>
</div>
</div>
<p><InputComp name="email" value={state.email} onChange={handleInputChange}/></p>
</div>
</form>
<div>
<button onClick={clearAllInputValue}>入力内容をクリア</button> <button onClick={submitAlert}>送信</button>
</div>
</>
);
};

export default ContactForm;