目次

はじめに

現在Typescript、React、Gatsby、NetlifyCMSを利用してこのブログを構築しています。まだお問い合わせ機能ができていないので、今回はそのフォーム処理を実装していきます。

フォームを実装するにあたり今回利用するライブラリはReduxとRedux Formです。今回はRedux Formの使い方をメインに紹介していきます。

なぜRedux Formを利用するのか

まず簡単にReduxを説明します。Redux利用することで簡単にコンポーネントとstate、stateを変更するための処理を分離して開発を進めることができます。これでグローバルにstateを管理することが非常に簡単になります。

次にRedux Formに関してです。Redux Formを利用することで通常Reduxで実装しなければならないコードを全てスキップすることができます。通常ReactであればinputタグにonChangeによるイベントハンドラをつけて、タグの要素を取得し、stateを変更。

それぞれの処理に対してvalidation関数を通してエラーを返してデザインに反映するといった処理を全てコーディングしなければなりません。

ところがRedux Formを利用することでdispatchやaction部分の実装を全てRedux Formが自動で担ってくれるため大幅なコーディングの時間短縮をすることができます。

実際にやらなければならないのはvalidation関数の実装とフォームコンポーネントのView部分の実装だけになります。非常に簡単です。

言葉で説明してもよく分からないので早速コーディングのサンプルを紹介していきます。

フォームの仕様

今回のフォームではブログのお問い合わせ機能を作りたいので以下のようにします

  • フォームの項目は氏名、メールアドレス、お問い合わせ内容とする。
  • 氏名、メールアドレス、お問い合わせ内容のいずれも必須項目で空ではいけない
  • メールアドレスはxxxx@xxx.zzzの形をとるものとする
  • お問い合わせ内容は400文字以下でなければならない
  • 上記のエラー判定処理は非同期で処理をしなければならない
  • 確認画面をテーブルで表示する
  • 確認画面で送信処理をすると送信完了画面が表示される。
  • 送信完了画面からお問い合わせ画面へ戻る

ざっとこんな感じの仕様で実装してきます。送信処理は含めません。また次回実装してみます。

実装

完成形はこちらになります。デザインはBootstrapのものをそのまま利用しています。

お問い合わせ

まずはredux、redux-formのインストールをしましょう。Typescriptの方がついたものも忘れずにインストールしておいてください

yarn add react-redux redux-form @types/react-redux @types/redux-form

以下はサンプルコードです。順番に説明していきます。まずルートコンポーネントにからstoreを供給できるようにしましょう。私の場合はLayoutコンポーネントがルートコンポーネントになるので以下のようになります。

const Layout: React.FC<{}> = ({ children }) => {
  return (
    <StaticQuery
      query={graphql`
        query LayoutQuery {
          site {
            buildTime(formatString: "DD.MM.YYYY")
          }
        }
      `}
      render={data => (
        <Provider store={store}>
          <ThemeProvider theme={theme}>
            <React.Fragment>
              <GlobalStyle />
              {children}
              <Footer>&copy; {split(data.site.buildTime, '.')[2]} by Mikihiro Saito. All rights reserved.</Footer>
            </React.Fragment>
          </ThemeProvider>
        </Provider>
      )}
    />
  );
};

続いてreducerのファイルを作成しましょう。私はここでstoreをエクスポートしています。ここでRedux Formをインポートしてreducerでまとめます。これでRedux Formで利用するstateの管理ができるようになりました。

reducer/index.ts

import { createStore, combineReducers } from 'redux';
import { reducer as reduxFormReducer } from 'redux-form';

const reducer = combineReducers({
  form: reduxFormReducer,
});

const store = createStore(reducer);

export default store;

先にバリデーション用の関数を用意しておきます。それぞれvalidationに必要なboolean型を返しています。今回必要なvalidationは先程の3つです。

  • 氏名、メールアドレス、お問い合わせ内容のいずれも必須項目で空ではいけない
  • メールアドレスはxxxx@xxx.zzzの形をとるものとする
  • お問い合わせ内容は400文字以下でなければならない

ということで空欄でないか、メールアドレスの型になっているか、400字以内かをそれぞれ判定する関数を定義しました。

utils/validation.ts

export const required = (value: string): boolean => (value ? true : false);

export const isEmail = (value: string): boolean => (/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value) ? true : false);

const maxLength = (value: string, max: number) => (value.length > max ? false : true);

export const maxLength400 = (value: string) => maxLength(value, 400);

次にフォームの作成を行います。先程作成したvalidationに関する関数を使ってvalidate関数をこのファイルに定義しました。エラーの内容に対してそれぞれの文字列を返します。

ContactFormコンポーネントではReduxのpropsが渡されるのでRedux Formの型定義を利用してPropsを型付けします。

またReduxのFieldタグを利用することでstateを管理してくれるようになります。Fieldタグ中のcomponentタグを渡すことで好きなUIのコンポーネントを利用してフォームを実装することになります。

最後のdestroyOnUnmountとforceUnregisterOnUnmountに関してですが、これはコンポーネントを切り替えてもvalueをそのフォームに対して保持しておくのに必要なので必ず設定しておきましょう。

それとこのファイル中で作成しているvalidate関数を一緒にreduxform内でまとめられるので設定しておきます。これで入力した値に対して非同期でvalidationをかけられるようになりました。

Organisms/ContactForm.tsx

import React from 'react';
import { Field, reduxForm, InjectedFormProps } from 'redux-form';
import Form from '../Molecules/Form';
import { required, isEmail, maxLength400 } from '../../utils/validation';
import { Values, Errors } from '../../models/form';

const validate = (values: Values) => {
  const errors: Errors = {};
  if (!required(values.name)) {
    errors.name = '必須の項目です';
  }

  if (!required(values.email)) {
    errors.email = '必須の項目です';
  } else if (!isEmail(values.email)) {
    errors.email = '無効のメールアドレスです';
  }

  if (!required(values.message)) {
    errors.message = '必須の項目です';
  } else if (!maxLength400(values.message)) {
    errors.message = '400字以内で記入ください';
  }

  return errors;
};

const ContactForm: React.SFC<InjectedFormProps> = ({ handleSubmit, submitting }) => (
  <form onSubmit={handleSubmit}>
    <div className="container">
      <Field name="name" type="text" component={Form} label="名前" />
      <Field name="email" type="text" component={Form} label="メールアドレス" />
      <Field name="message" type="textarea" component={Form} label="お問い合わせ内容" />
      <div className="form-group">
        <button className="btn btn-primary" type="submit" disabled={submitting}>
          確認画面へ
        </button>
      </div>
    </div>
  </form>
);

export default reduxForm({
  validate,
  form: 'contactForm',
  destroyOnUnmount: false,
  forceUnregisterOnUnmount: true,
})(ContactForm);

上記の中で型ファイルもありましたね。formでキーとそれに対するvalueは全て文字列なので以下のように定義します。これはエラーを返すオブジェクトも同様です。

FieldPropsは後ほど使います。これは先程説明したcomponent属性に渡したコンポーネント、つまりFormコンポーネントに渡されるPropsの定義です。

models/form

export interface Values {
  [key: string]: string;
}

export interface Errors {
  [key: string]: string;
}

export interface FieldProps {
  input: any;
  label: string;
  type: string;
  meta: {
    touched: string;
    error: string;
    warning: string;
  };
}

それではそのFormコンポーネントを作っていきましょう。このコンポーネント内にはテキストに入力された値やエラーの内容がpropsとして返ってきます。それを利用してフォームにvalidationエラーのUIを適応させていきましょう。

まずgetEditorStyleではエラーが発生した場合にテキストが赤枠に変化するようになっています。またエラー内容がテキスト欄の下に表示されまず。

ちなみにtouchedとは何かというと一度テキスト欄にフォーカスを当てて、外したらtrueが返ってきます。これにより未入力状態でのエラー表示を避けることができるのです。

またtypeでテキストかテキストエリアかの分岐もしてあります。仮に他の種類のフォームを設置したい場合にはtextareaの下に増やしていくことも当然可能です。

Molecules/Form.tsx

import React from 'react';
import { FieldProps } from '../../models/form';

const Form: React.SFC<FieldProps> = ({ input, label, type, meta: { touched, error, warning } }) => {
  const getEditorStyle = touched && !input.value ? { borderColor: 'red' } : {};
  return (
    <div className="form-group">
      {label && <label>{label}</label>}

      {type === 'text' && <input {...input} className="form-control" placeholder={label} type={type} style={getEditorStyle} />}

      {type === 'textarea' && <textarea {...input} className="form-control" placeholder={label} type={type} style={getEditorStyle} />}

      {touched && (
        <div style={{ color: 'red', fontSize: '80%' }}>
          <p>{(error && <span>{error}</span>) || (warning && <span>{warning}</span>)}</p>
        </div>
      )}
    </div>
  );
};

Form.defaultProps = {
  type: 'text',
};

export default Form;

続いて確認画面を実装してみましょう。確認画面では先程のフォーム画面で入力した内容をテーブルで表示できるようにしています。Propsの片付けがややこしいですがTypescriptの場合は以下のようにClassコンポーネントで記述するとエラーなく動かすことができました。

この確認画面ではReduxを利用してグローバルStateから値を引き出す必要があるので後ほどContanerを作成します。

またこのページではお問い合わせに戻るためのボタンがありますが、これも後ほど解説します。

Redux Formを使ってエクスポートするのは先程のContactFormコンポーネントと同様です。

Organisms/ContactConfirm.tsx

import React from 'react';
import { reduxForm, InjectedFormProps } from 'redux-form';

interface ContactProps {
  name: string;
  email: string;
  message: string;
  previousPage: () => void;
}

class ContactConfirm extends React.Component<ContactProps & InjectedFormProps<{}, ContactProps>> {
  render() {
    const { handleSubmit, submitting, previousPage, name, email, message } = this.props;
    return (
      <form onSubmit={handleSubmit}>
        <div className="container">
          <table className="table">
            <tbody>
              <tr>
                <th>名前</th>
                <td>{name}</td>
              </tr>
              <tr>
                <th>メールアドレス</th>
                <td>{email}</td>
              </tr>
              <tr>
                <th>お問い合わせ内容</th>
                <td>{message}</td>
              </tr>
            </tbody>
          </table>
          <div className="form-group">
            <button className="btn btn-primary" type="submit" disabled={submitting}>
              送信する
            </button>
            <button className="btn btn-secondary" type="button" disabled={submitting} onClick={previousPage}>
              お問い合わせ画面へ戻る
            </button>
          </div>
        </div>
      </form>
    );
  }
}

export default reduxForm<{}, ContactProps>({
  form: 'contactForm',
  destroyOnUnmount: false,
  forceUnregisterOnUnmount: true,
})(ContactConfirm);

それでは確認画面のContainerを作成しましょう。これは通常のReduxの使い方と同じです。本来であればFormValueSelectorというRedux FormのAPIがあるのですが、どうもうまく動きませんでした。なので今回はmapStatePropsでContactConfirmにPropsを渡します。

これは通常のReduxと使用方法は変わりませんので、いつも通りにContainerを実装します。

containers/ContactConfirm.tsx

import { connect } from 'react-redux';
import ContactConfirm from '../components/Organisms/ContactConfirm';

const mapStateToProps = (state: any) => {
  const { name, email, message } = state.form.contactForm.values;
  return {
    name,
    email,
    message,
  };
};

export default connect(mapStateToProps)(ContactConfirm);

ここまでくれば後少しです。次に送信完了画面を実装してみましょう。下はお好みのメッセージに変えてあげてください。ここでもRedux Formから値を引き出しています。

そして最後にお問い合わせの画面へ戻るボタンを設置しています。

Organisms/ContactCompleted.tsx

import React from 'react';
import { reduxForm, InjectedFormProps } from 'redux-form';

interface ContactProps {
  name: string;
  email: string;
}

class ContactCompleted extends React.Component<ContactProps & InjectedFormProps<{}, ContactProps>> {
  render() {
    const { handleSubmit, submitting, name, email } = this.props;
    return (
      <form onSubmit={handleSubmit}>
        <div className="container">
          <p>{name}様、お問い合わせありがとうございます。</p>
          <p>
            お問い合わせ内容を確認次第、{email}宛へ
            <br />
            ご連絡をさせていただきますのでもう少々お待ちください
          </p>
          <p>これからも「ぼほーらの」をよろしくお願いいたします</p>
          <p>Mikihiro Saito</p>
          <div className="form-group">
            <button className="btn btn-primary" type="submit" disabled={submitting}>
              お問い合わせ画面へ戻る
            </button>
          </div>
        </div>
      </form>
    );
  }
}

export default reduxForm<{}, ContactProps>({
  form: 'contactForm',
})(ContactCompleted);

確認画面と同様に完了画面のContainerも作成しましょう。messageを削っただけなので簡単ですね。

import { connect } from 'react-redux';
import ContactCompleted from '../components/Organisms/ContactCompleted';

const mapStateToProps = (state: any) => {
  const { name, email } = state.form.contactForm.values;
  return {
    name,
    email,
  };
};

export default connect(mapStateToProps)(ContactCompleted);

そしてこれが本当に最後です。Contactページにこれらお問い合わせ画面、確認画面、送信完了画面を組み込んであげます。

initialStateにはコンポーネントがマウントされた際の1ページ目、つまりお問い合わせフォームの画面を表示しています。

そして完了画面までいってホーム画面に戻るを押したらinitialStateで初期のstateの状態を入れてリセット。

これでコンタクトフォームのページ切り替えの実装が完了しました。

pages/contact.tsx

import React from 'react';
import Helmet from 'react-helmet';
import { Link } from 'gatsby';
import Layout from '../components/Layout';
import { Wrapper, Content, SectionTitle } from '../components/_nano/style';
import Header from '../components/Organisms/Header';
import config from '../../config/SiteConfig';
import PageProps from '../models/PageProps';
import ContactForm from '../components/Organisms/ContactForm';
import ContactConfirm from '../containers/ContactConfirm';
import ContactCompleted from '../containers/ContactCompleted';

interface State {
  page: number;
}

class ContactPage extends React.Component<PageProps, State> {
  initialState = {
    page: 1,
  };

  state = this.initialState;

  nextPage = () => {
    if (this.state.page === 3) {
      this.setState(this.initialState);
    } else {
      this.setState({ page: this.state.page + 1 });
    }
  };

  previousPage = () => {
    this.setState({ page: this.state.page - 1 });
  };

  render() {
    const { page } = this.state;
    return (
      <Layout>
        <Helmet title={`Contact | ${config.siteTitle}`} />
        <Header>
          <Link to="/">{config.siteTitle}</Link>
          <SectionTitle uppercase={true}>お問い合わせ</SectionTitle>
        </Header>
        <Wrapper>
          <Content>
            <p>
              WEBサイト制作から業務用ツール作成、CodeGridでのメンター、その他意見等を受付しております。
              <br />
              お気軽にお問い合わせください
            </p>
            <div className="container">
              <div className="row">
                <div className="col-md-6 col-md-offset-3">
                  <div className="well well-sm">
                    {page === 1 && <ContactForm onSubmit={this.nextPage} />}
                    {page === 2 && <ContactConfirm previousPage={this.previousPage} onSubmit={this.nextPage} />}
                    {page === 3 && <ContactCompleted onSubmit={this.nextPage} />}
                  </div>
                </div>
              </div>
            </div>
          </Content>
        </Wrapper>
      </Layout>
    );
  }
}
export default ContactPage;

まとめ

Redux Formを使うことでactionやdispatchの実装をすることなく簡単にフォームの実装をすることができました。これを応用すればどのようなフォームにも対応できるようになります。

これからもReduxを活用して爆速で開発を進めていきましょう!