【React+Typescript】初心者向けの簡易的なフォームを作る

  • React
  • Styled-Component
  • Typescript

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.タグ名 という風に定義します。例えば SFormInputstyled.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 としています。
  • handleOnSubmitloading が 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 はぐるぐるするやつ(他のタイプはこちらを参照)。
  • heightwidth はアイコンの縦と横の大きさです。

わざわざオリジナルコンポーネントで 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 を使うと作りが全然違うものになってしまうので、今回のフォームにすぐ適用することができないことは内緒です )。