React + Typescript で簡易的なフォームを作ってみます(Typescript くんの出番はあまりないですが)。
フォームのパーツ、フロントの入力と送信までの処理を作るだけなので、バックエンドの送信処理は別途用意する必要があります。
完成図としては下記画像のような感じです。
それでは順に作っていきましょう。
フォームパーツを作成する
まず React コンポーネントを定義し、その中にフォームのパーツとなる input を作成していきます。今回は css として Styld Component で定義し、それぞれのパーツを簡単にスタイリングしてみました。
import React from 'react';
import styled from 'styled-components';
const SForm = styled.form`
display: grid;
grid-template: auto / 100%;
gap: 30px;
border-radius: 8px;
margin: auto;
`;
const SFormHead = styled.div`
margin-bottom: 5px;
font-weight: bold;
`;
const SFormInput = styled.input`
border: none;
width: 100%;
background-color: #f1f1f1;
padding: 10px;
border-radius: 4px;
transition: 0.3s;
&:focus {
outline: none;
background: #e7e7e7;
transition: 0.3s;
}
`;
const SFormTextArea = styled.textarea`
border: none;
width: 100%;
background-color: #f1f1f1;
padding: 10px;
border-radius: 4px;
height: 10em;
transition: 0.3s;
&:focus {
outline: none;
background: #e7e7e7;
transition: 0.3s;
}
`;
export const Contact = () => {
return (
<SForm>
<div>
<SFormHead>お名前*</SFormHead>
<SFormInput type={"text"} value="" required placeholder="おなまえ" />
</div>
<div>
<SFormHead>メールアドレス</SFormHead>
<SFormInput type={"email"} value="" />
</div>
<div>
<SFormHead>内容*</SFormHead>
<SFormTextArea
required
placeholder="内容をご入力ください"
></SFormTextArea>
</div>
</SForm>
)
}
- styled component では
styled.タグ名
という風に定義します。例えばSFormInput
はstyled.input
として定義しているので、<input … /> タグで HTML 上に出力されます。なので、<input ... />
タグで使用できる属性 required や placeholder などは普通に使うことができます。 - styled component を使う予定がない場合は、
SForm
->form
タグなど、定義を見ながら差し替えてみてください。 - styled component のタグを
S
から始めているのは、styled component というのが分かりやすいように単に Styled の頭文字を取っているだけですので、命名はお好みで。
useState でフォームの value を受け取る
今のままでは input になにか入力しても、ただ入力されるだけで値を受け取ることができません。なので input の onChange
イベントを使って、input になにか入力されたタイミングで入力値を受け取り、それを格納するための箱を作ります。それには useState
を使いましょう。
...
export const Contact = () => {
const [name, setName] = useState('');
const [mailAddress, setMailAddress] = useState('');
const [body, setBody] = useState('');
const handleOnChangeName = (event: { target: HTMLInputElement }) => {
setName(event.target.value);
};
const handleOnChangeMailAddress = (event: { target: HTMLInputElement }) => {
setMailAddress(event.target.value);
};
const handleOnChangeBody = (event: { target: HTMLTextAreaElement }) => {
setBody(event.target.value);
};
return (
<SForm>
<div>
<SFormHead>お名前*</SFormHead>
<SFormInput type={"text"} value={name} required placeholder="おなまえ" onChange={handleOnChangeName} />
</div>
<div>
<SFormHead>メールアドレス</SFormHead>
<SFormInput type={"email"} value={mailAddress} onChange={handleOnChangeMailAddress} />
</div>
<div>
<SFormHead>内容*</SFormHead>
<SFormTextArea
required
placeholder="内容をご入力ください"
onChange={handleOnChangeBody}
></SFormTextArea>
</div>
</SForm>
)
}
useState()
の第一引数には初期値を入れます。今回特に初期値はいらないので、ここでは空文字をセットします。useState
は[値の箱, 値のセット関数]
の2つを生成することができ、このセット関数でonChange
、つまり入力時に値をセットし続けるようにします。たとえばsetName
でセットした値はname
に格納されていきます。
実際の動きを確認したければ、console.log で出力してみましょう。お名前入力欄に入力すると入力値が出力されるのがわかると思います。
const handleOnChangeName = (event: { target: HTMLInputElement }) => {
setName(event.target.value);
console.log(name)
};
送信ボタンを作る
ここまでで state で入力値を受け取ることができるようになりました。ところが受け取るばかりで送信できません。次は送信できるよう submit ボタンを作りましょう。
import axios from 'axios';
...
const SButton = styled.button`
border: none;
background-color: #555;
color: #fff;
padding: 10px;
min-height: 40px;
display: flex;
width: 150px;
text-align: center;
transition: 0.3s;
border-radius: 4px;
margin: auto;
justify-content: center;
align-items: center;
&:hover {
background-color: #999;
cursor: pointer;
}
`;
export const Contact = () => {
...
const handleOnSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
axios
.post('APIのURLなど', {
name,
mailAddress,
body,
})
.then(r => console.log("送信!"))
.catch(r => console.error("失敗!", r));
};
return (
<SForm onSubmit={handleOnSubmit}>
...
<div>
<SFormHead>内容*</SFormHead>
<SFormTextArea
required
placeholder="内容をご入力ください"
onChange={handleOnChangeBody}
></SFormTextArea>
</div>
<SButton type="submit">送信</SButton>
</SForm>
);
- button タグも styled component で定義してみました。type が submit なので、クリックすると form タグの
onSubmit
イベントが実行されます。今回は定義した関数handleOnSubmit
が発火するようになっています。 - form タグは、デフォルトでは submit 時に action 属性のパスに遷移するようになっています。今回は form タグに action 属性を入れていませんが、入れていない場合は同一ページに遷移するためページの読み込みが行われます。読み込みはしてほしくないので
onHandleSubmit
の最初にevent.preventDefault()
をつけることで、デフォルト処理(ここでは action のパスへの遷移)の実行を防止します。 - 今回は例として
axios
を使って'APIのURLなど'
に対して post リクエストを送るようにしています。実際は、メール送信用の API URL を記述するようなイメージです。
axios でローディングを作り、二重送信を防止する
最低限の機能を作ることはできましたが、今のままだと送信ボタンを連打できてしまいます。コンバージョンが欲しいとは言え、送信し放題なのは困ります。送信中はローディングアイコンを表示させ、ついでに送信ボタンを押せなくしておきましょう。
export const Contact = () => {
const [name, setName] = useState('');
const [mailAddress, setMailAddress] = useState('');
const [body, setBody] = useState('');
+ const [loading, setLoading] = useState(false);
const handleOnSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
+ if (loading) return;
+ setLoading(true)
axios
.post('APIのURLなど', {
name,
mailAddress,
body,
})
.then(r => {
console.log("送信!")
})
.catch(r => {
console.error("失敗!", r)
})
.finally(r => {
setLoading(false)
});
};
- まず
useState
を使ってフォームがローディング中かどうかを保存しておく箱を作りましょう。最初からローディング状態なのは変なので、初期値はfalse
としています。 handleOnSubmit
でloading
が true であるならreturn
を行い、axios
を走らせないようにします。これでローディング中にボタンを押しても API の URL にリクエストを送信することがなくなり、二重リクエストの防止になります。- 送信処理の前に loading に true をセットしてローディング状態であることにします。そして、送信処理が終わるもしくはエラーになったタイミングで再度 loading を false にセットし、ローディング中でないことを示します。
これでローディングかどうかを判定できるようになりましたが、送信ボタンを押してもテキストが「送信」のままなので、ユーザーからすると今送信中なのかどうかがよく分かりません。なので、ぐるぐるするアイコンでもつけてみましょう。
react-loading でローディングアイコンを表示する
自前で用意するのが面倒…。そんなときには react-loading というライブラリを使ってみましょう。簡単にローディングしてる雰囲気を醸し出しているアイコンを設置することができます。
yarn add react-loading
ダウンロードできたら import して使うことができますが、今回は Loading
というオリジナルのコンポーネントを作り、そこで react-loading を呼び出すことにします。
Loading.tsx
import React from 'react';
import ReactLoading from 'react-loading';
export const Loading = () => {
return <ReactLoading type="spin" height={20} width={20} />;
};
type
… ローディングアイコンのタイプ。spin
はぐるぐるするやつ(他のタイプはこちらを参照)。height
とwidth
はアイコンの縦と横の大きさです。
わざわざオリジナルコンポーネントで react-loading を読み込まなくても、直接 react-loading コンポーネントをフォーム側で呼び出せばいいのでは?と思うのですが、もし react-loading が古くなり別のものに差し替えたくなったとき、使用箇所すべてを差し替える必要が出てきます。
ですがオリジナル Loading コンポーネントでラップしておくと、このコンポーネントの内部で使っている Loading ライブラリ1つを変更すれば全箇所変更されるので、今後の修正に対応しやすくするためにこのような形にしてみました。まあ、どっちでもいいですけどね!
それではローディング中はぐるぐるアイコンが表示されるようにしてみます。ついでに、loading 中は button を disabled にしておきボタンを押せなくしてしまいましょう。
import React from 'react';
import styled from 'styled-components';
import Loading from './Loading'
...
return (
...
<SButton type="submit" disabled={loading}>{loading ? <Loading /> : `送信`}</SButton>
...
)
- HTML タグでは
disabled
とつけるだけで true 判定となるのでボタンが押せなくなりますが、もし true にしたり false にしたい場合は、disabled="false"
のように明示してあげる必要があります。今回は loading 変数を代入し、ローディング中なら true、ローディングしていないなら false でボタンの活性/非活性が切り替わります。 - 今回はやってませんが、
:disabled
のときに css でスタイリングするとユーザーにより分かりやすいかもしれません(マウスポインタとかを禁止っぽいやつcursor: not-allowed
にするなど)
axios で送信完了と送信エラーを表示する
ここまででローディング中の判定もできるようになりましたが、送信が終わった後も「送信」ボタンに戻るだけなので、ユーザーからすると送信完了したのか、それともエラーになったのかがよく分かりません。エラーだったのに送信完了したと勘違いして帰られては困ります。ちゃんと判別できるようにしてみましょう。
export const Contact = () => {
...
const [sent, setSent] = useState(false);
const [error, setError] = useState(false);
...
- いつものように
useState
で完了フラグ(sent
)とエラーフラグ(error
)の2つの値を持てるようにします。最初から送信完了してたり、エラーになっているとおかしなフォームになってしまうので、初期値は false です。
axios でリクエストが成功/失敗したときにそれぞれセットしましょう。
...
setError(false)
axios
.post('APIのURLなど', {
name,
mailAddress,
body,
})
.then(r => {
console.log("送信!")
setSent(true)
})
.catch(r => {
console.error("失敗!", r)
setError(true)
})
.finally(r => {
setLoading(false)
});
- axios で API を叩く前にエラーを false にしておきます。この error を元にしてエラーメッセージ等を表示している場合、送信失敗してもう一度送信を試したときにも表示されっぱなしだと違和感があるので…
これで送信完了とエラー判定を行えるようになったので、UI 上にも反映できるようにします。また、エラーメッセージも表示できるようにしてみましょう。
...
const SSent = styled.p`
text-align: center;
margin: 0;
`;
const SError = styled.p`
text-align: center;
background-color: #e07171;
color: #fff;
border-radius: 5px;
padding: 5px;
margin: 0;
`;
export const Contact = () => {
...
return (
...
{sent ? (
<SSent>ありがとうございます。メッセージは送信されました。</SSent>
) : (
<SButton type="submit">{loading ? <Loading /> : `送信`}</SButton>
)}
{error && (
<SError>
エラーが発生しました。もう一度やり直してください。
</SError>
)}
</SForm>
);
sent
で送信されていれば完了メッセージ、そうでないなら送信ボタンを表示しておきます。error
でエラーがあるならメッセージを表示させておきます。
フォームの完成
最後に適当なタイトルをつけて完成です。
Contact.tsx
import axios from 'axios';
import React, { useState } from 'react';
import styled from 'styled-components';
import { Loading } from './Loading';
const SForm = styled.form`
display: grid;
grid-template: auto / 100%;
gap: 30px;
border-radius: 8px;
margin: auto;
`;
const SFormHead = styled.div`
margin-bottom: 5px;
font-weight: bold;
`;
const SFormInput = styled.input`
border: none;
width: 100%;
background-color: #f1f1f1;
padding: 10px;
border-radius: 4px;
transition: 0.3s;
&:focus {
outline: none;
background: #e7e7e7;
transition: 0.3s;
}
`;
const SFormTextArea = styled.textarea`
border: none;
width: 100%;
background-color: #f1f1f1;
padding: 10px;
border-radius: 4px;
height: 10em;
transition: 0.3s;
&:focus {
outline: none;
background: #e7e7e7;
transition: 0.3s;
}
`;
const SButton = styled.button`
border: none;
background-color: #555;
color: #fff;
padding: 10px;
min-height: 40px;
display: flex;
width: 150px;
text-align: center;
transition: 0.3s;
border-radius: 4px;
margin: auto;
justify-content: center;
align-items: center;
&:hover {
background-color: #999;
cursor: pointer;
}
`;
const SSent = styled.p`
text-align: center;
margin: 0;
`;
const SError = styled.p`
text-align: center;
background-color: #e07171;
color: #fff;
border-radius: 5px;
padding: 5px;
margin: 0;
`;
export const Contact = () => {
const [name, setName] = useState('');
const [mailAddress, setMailAddress] = useState('');
const [body, setBody] = useState('');
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState(false);
const handleOnChangeName = (event: { target: HTMLInputElement }) => {
setName(event.target.value);
};
const handleOnChangeMailAddress = (event: { target: HTMLInputElement }) => {
setMailAddress(event.target.value);
};
const handleOnChangeBody = (event: { target: HTMLTextAreaElement }) => {
setBody(event.target.value);
};
const handleOnSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (loading) return;
setError(false);
setLoading(true);
axios
.post('APIのURL', {
name,
mailAddress,
body,
})
.then(() => {
setSent(true);
})
.catch((r) => {
console.error(r);
setError(true);
})
.finally(() => {
setLoading(false);
});
};
return (
<SForm onSubmit={handleOnSubmit}>
<h1>CONTACT US</h1>
<div>
<SFormHead>お名前*</SFormHead>
<SFormInput type={'text'} value={name} required onChange={handleOnChangeName} />
</div>
<div>
<SFormHead>メールアドレス</SFormHead>
<SFormInput value={mailAddress} onChange={handleOnChangeMailAddress} />
</div>
<div>
<SFormHead>内容*</SFormHead>
<SFormTextArea
required
onChange={handleOnChangeBody}
placeholder="内容をご入力ください"
></SFormTextArea>
</div>
{sent ? (
<SSent>ありがとうございます。メッセージは送信されました。</SSent>
) : (
<SButton type="submit">{loading ? <Loading /> : `送信`}</SButton>
)}
{error && (
<SError>
エラーが発生しました。もう一度やり直してください。
</SError>
)}
</SForm>
);
};
Loading.tsx
import React from 'react';
import ReactLoading from 'react-loading';
export const Loading = () => {
return <ReactLoading type="spin" height={20} width={20} />;
};
今回はバリデーションは HTML 5 の標準機能にお任せしてしまってますが、React Hook Form など使うとよりユーザーフレンドリーなフォームになるかと思います( React Hook Form を使うと作りが全然違うものになってしまうので、今回のフォームにすぐ適用することができないことは内緒です )。