こんにちは。西日本テクノロジー&イノベーション室の藤田です。

技術的に強くなりたいと言っている新卒3年目です。

最近フィットボクシングを始めました。日々フックを打つ手が力強くなっていくのを実感しています。

この前書きは以下の内容とはまったく関係ありません。

はじめに

React + Railsでサービス開発をしていて、その時に実装したバリデーションありのフォームについてまとめます。

フォームでやっていることは、以下の3点です。

  • APIから初期値を取得して各フォームに設定する
  • ユーザーが入力した値に対してバリデーションを行い、結果次第でエラーメッセージを表示させる
  • 入力項目をpostする

やっていることは特殊なところもなく、フォームとしては一般的な内容かと思います。本記事では、ソースコードを参照しながらやっていることを一通り説明します。

実装

ここでは、名前と電話番号を入力するフォームを実装します。

今回、あらかじめ外部SNSログインをしてもらっている想定で、初期値はその外部SNSから取得します。また、名前も電話番号もどちらも必須項目として、空欄であればエラーメッセージを表示させ、バリデーションがOKであればpostします。

フォーム画面を開いてから情報を登録するまでの一連の流れに沿って、ソースコードを参照しながらポイントを説明します。

流れは以下の通りです。

  • stateの初期値設定
  • APIから初期値を取得する
  • ユーザーが必要な項目を入力する
  • postする前に入力項目をチェックする
  • 入力項目をpostする

stateの初期値設定

入力項目とそれに対応するエラーメッセージ、処理の状態を管理します。

エラーメッセージは、項目に対して複数エラーメッセージがある場合を考慮して配列で持ちます。また、全てのエラーメッセージをクライアント側で用意せず、APIから受け取ったエラーメッセージを出力できるように構造を合わせています。

    this.state = {
      name: "",
      phone: "",
      errors: {
        name: [],
        phone: [],
        _global: []
      },
      processing: false
    };
  }

APIから初期値を取得する

componentDidMount()でAPIから初期値を取得してstateに設定します。

外部SNSにログインしておらず、初期値が取得できなかった場合は401エラーが返ってきます。401エラーが返ってきた場合は、正規のフローでユーザー登録をしていないものとみなして、LP(ランディングページ)に遷移します。

  componentDidMount() {
    this.fetchUserData();
  }

  async fetchUserData() {
    try {
      const resp = await axios.get("/api/users/sign_up");
      const json = resp.data;
      this.setState({ ...json });
    } catch (e) {
      if (e.response.status === 401) {
        window.location.href = "LPのURL";
      }
    }
  }

ユーザーが必要な項目を入力する

見やすいようにユーザー名の入力フォームのみ抽出しています。

入力フォームにはAnt Design MobileのInputItemを使っています。ユーザーが登録画面を開いて項目に入力している時点ではまだバリデーションは行っていませんが、もし不正な値と判断された場合errors.nameに設定されているエラーメッセージが入力フォームの直下に赤字で表示されるようにしています。バリデーションは入力項目をpostするときに行います。

また、ユーザーが入力フォームを更新する度、当該項目に関するエラーメッセージを消しています。エラーメッセージが出力され、入力値を修正している間ずっとエラーメッセージが出続けることを避けるためです。

  const { name, phone, processing, errors } = this.state;
  return (
    <div className="signup">
      <List>
        <InputForm placeholder="ユーザー名を入力してください" onChange={name =>
          this.setState({
            name,
            errors: { ...errors, name: [] }
          })
        }
          value={name} maxLength="20" itemname="ユーザー名" message={errors.name} />
const InputForm = (props) => {
  return (
    <React.Fragment>
      <InputItem {...props}>{props.itemname}</InputItem>
      <ErrorMessage message={props.message} />
    </React.Fragment>
  );
};

const ErrorMessage = (props) => {
  const errorstyles = {
    color: "#fc0101"
  };
  const messages = props.message
  
  return (
    <React.Fragment>
      {messages !== null && 
        messages.map(message =>
        <p className="error" style={errorstyles} key={message}>
          {message}
        </p>
      )}
    </React.Fragment>
  );
}

postする前に入力項目をチェックする

ユーザーが入力し終わって、登録ボタンを押したタイミングでバリデーションをかけます。

まずエラーメッセージをリセットし、stateprocessingtrueに切り替えます。

processing trueであれば登録ボタンを非活性にしてアイコンをloadingに変更しておきます。ここではAnt Design MobileのButtonIconを使用しています。

  async onSignUpButtonClick() {
    const { name, phone } = this.state;
    const { history } = this.props;
    this.setState({
      errors: {
        name: [],
        phone: [],
        _global: []
      },
      processing: true
    });
  <Button icon={processing ? "loading" : "check-circle-o"} inline disabled={processing} style={{ color: "#f24d99" }} onClick={this.onSignUpButtonClick.bind(this)}>
    登録
  </Button>

続いて、必須項目の入力チェックをします。もしname""nullの場合は、エラーメッセージをnameErrorsに設定してreturnします。すると、前項で説明したように、入力フォーム直下にエラーメッセージが表示されます。

  const nameErrors = validateRequired(name, "ユーザ名を入力してください");
  const phoneErrors = validateRequired(phone, "電話番号を入力してください");
  if (nameErrors || phoneErrors) {
    this.setState({
      errors: { name: nameErrors, phone: phoneErrors, _global: [] },
      processing: false
    });
    return;
  }
function validateRequired (property, message){
  const error = property === "" || property === null ? [message] : null;
  return error;
}

入力項目をpostする

前項に引き続き、onSignUpButtonClick()内の処理です。

バリデーションの結果がOKであれば、入力項目をpostし、その結果によって処理を分岐させます。

200が返ってきたら、次のページへ遷移させます。

401エラーが返ってきたら、正規のフローでユーザー登録をしていないものとみなしてLPに遷移します。

その他のエラーが起きたら、APIから返されたエラーメッセージをsetStateします。

  try {
    await axios.post("/api/users", { name, phone });
    this.setState({
      processing: false
    });
    history.push("次のページ");
  } catch (e) {
    if (e.response.status === 401) {
      window.location.href = "LPのURL";
      return;
    }
    const errors = e.response.data;
    const _global = ["システムでエラーが起きました"];
    this.setState({
      errors: { ...errors, _global },
      processing: false
    });
  }

コード全体

以上の説明をふまえて、フォームのコード全体は次の通りです。

import React from "react";
import { WhiteSpace, Button, InputItem, List } from "antd-mobile";
import axios from 'axios';

const InputForm = (props) => {
  return (
    <React.Fragment>
      <InputItem {...props}>{props.itemname}</InputItem>
      <ErrorMessage message={props.message} />
    </React.Fragment>
  );
};

const ErrorMessage = (props) => {
  const errorstyles = {
    color: "#fc0101"
  };
  const messages = props.message
  
  return (
    <React.Fragment>
      {messages !== null && 
        messages.map(message =>
        <p className="error" style={errorstyles} key={message}>
          {message}
        </p>
      )}
    </React.Fragment>
  );
}

function validateRequired (property, message){
  const error = property === "" || property === null ? [message] : null;
  return error;
}

export default class SignUpRegistrationPage extends React.Component {

  constructor(props) {
    super(props);
    this.state = {
      name: "",
      phone: "",
      errors: {
        name: [],
        phone: [],
        _global: []
      },
      processing: false
    };
  }

  componentDidMount() {
    this.fetchUserData();
  }

  async fetchUserData() {
    try {
      const resp = await axios.get("/api/users/sign_up");
      const json = resp.data;
      this.setState({ ...json });
    } catch (e) {
      if (e.response.status === 401) {
        window.location.href = "LPのURL";
      }
    }
  }

  async onSignUpButtonClick() {
    const { name, phone } = this.state;
    const { history } = this.props;
    this.setState({
      errors: {
        name: [],
        phone: [],
        _global: []
      },
      processing: true
    });
    const nameErrors = validateRequired(name, "ユーザ名を入力してください");
    const phoneErrors = validateRequired(phone, "電話番号を入力してください");
    if (nameErrors || phoneErrors) {
      this.setState({
        errors: { name: nameErrors, phone: phoneErrors, _global: [] },
        processing: false
      });
      return;
    }
    try {
      await axios.post("/api/users", { name, phone });
      this.setState({
        processing: false
      });
      history.push("次のページ");
    } catch (e) {
      if (e.response.status === 401) {
        window.location.href = "LPのURL";
        return;
      }
      const errors = e.response.data;
      const _global = ["システムでエラーが起きました"];
      this.setState({
        errors: { ...errors, _global },
        processing: false
      });
    }
  }

  render() {
    const { name, phone, processing, errors } = this.state;
    return (
      <div className="signup">
        <List>
          <InputForm placeholder="ユーザー名を入力してください" onChange={name =>
            this.setState({
              name,
              errors: { ...errors, name: [] }
            })
          }
            value={name} maxLength="20" itemname="ユーザー名" message={errors.name} />
          <WhiteSpace size="xl" />
          <InputForm type="number" placeholder="0912345678" maxLength="20" onChange={phone =>
            this.setState({
              phone,
              errors: { ...errors, phone: [] }
            })
          }
            value={phone} itemname="電話番号" message={errors.phone} labelNumber={7} />
          <WhiteSpace size="sm" />
        </List>
        <WhiteSpace size="xl" />
        <Button icon={processing ? "loading" : "check-circle-o"} inline disabled={processing} style={{ color: "#f24d99" }} onClick={this.onSignUpButtonClick.bind(this)}>
          登録
        </Button>
        <ErrorMessage message={errors._global} />
        <WhiteSpace />
      </div>
    );
  }
}

おわりに

Reactでバリデーションありのフォームを実装する方法をまとめました。

エラーメッセージを配列で持つところ、APIからエラーメッセージを受け取れるように構造を揃えているところがポイントです。

バリデーションの種類を増やしてエラーメッセージが増えた場合もこの実装であれば拡張が容易です。


本コンテンツはクリエイティブコモンズ(Creative Commons) 4.0 の「表示—継承」に準拠しています。