React + MUI でメール送信フォームをつくる [バックエンドはAWS]

こんにちは、広野です。

MUI をご存知でしょうか。マテリアルデザインという考え方に基づいてデザインされた React 用 WEB パーツ集です。これを使うと、デザインセンスが無くても見栄えの良いサイトを開発できます。

MUI: The React component library you always wanted
MUI provides a simple, customizable, and accessible library of React components. Follow your own design system, or start with Material Design. You will develop ...

本記事では、MUI を使ったメール送信フォーム画面を作ってみます。

実は、React 初心者にとってフォーム開発はなにげにハードルが高いです。state や handleChange の概念が理解できていないと、書いたコードが全然動かず、あきらめてしまう方をこれまで何人も見てきました。そのため、本記事では handleChange について突っ込んで解説します。

さらに!
フロントエンドだけ開発してもメールフォームは成り立ちませんので、バックエンドでメール送信する仕組みも AWS CloudFormation で作れるテンプレートを用意しました。フロントエンドからバックエンドまで一気通貫したメール送信フォーム開発ができますので、ご活用頂ければと思います。

つくりたいもの

こんな感じです。

細かい作り込みは後述しますが、表に見える WEB パーツとしては以下の3つを使用します。それぞれ、リンク先にはサンプルデザインとサンプルコードがいくつも置いてあるので、それを参考に、一般的な HTML タグの代わりに MUI が提供するコードを React コードの中に埋め込んでいくことになります。

  • テキスト入力フォーム
Text field React component - Material UI
Text fields let users enter and edit text.
  • ボタン
React Button component - Material UI
Buttons allow users to take actions, and make choices, with a single tap.
  • アイコン
React Icon Component - Material UI
Guidance and suggestions for using icons with MUI.

環境

  • React 18
  • MUI 5.8.4

MUI インストール手順

MUI の公式ドキュメント通りなのですが、React が入っている状態で以下のコマンドを実行します。(npm を使っています)
アイコンを使う場合は、アイコンのドキュメントに書いてある追加モジュール @mui/icons-material もインストールします。

npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
Installation - Material UI
Install Material UI, the world's most popular React UI framework.

サンプル React コード

この画面を1つの関数コンポーネントにしてみました。

  • Feedback.js
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import FormControl from '@mui/material/FormControl';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import SendIcon from '@mui/icons-material/Send';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import axios from 'axios';

const Feedback = () => {
  //メールアドレスフォーマットチェック用正規表現定義
  const regexpEmail = /^[a-zA-Z0-9_+-]+(.[a-zA-Z0-9_+-]+)*@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/;
  //見た目の設定
  const theme = createTheme({
    typography: {
      fontFamily: "inherit",
      button: {
        textTransform: "none",
        fontFamily: "inherit"
      }
    }
  });
  const textfieldStyles = {
    backgroundColor: "#ffffff",
    fontFamily: "inherit"
  };
  const textlabelStyles = {
    fontFamily: "inherit"
  };
  //state定義
  const [values, setValues] = useState(
    {
      fbname: "",
      fbemail: "",
      fbmessage: "",
      isSubmitted: false
    }
  );
  //文字入力の度にstate更新
  const handleChange = (e) => {
    const target = e.target;
    const value = target.type === "checkbox" ? target.checked : target.value;
    const name = target.name;
    setValues({ ...values, [name]: value });
  };
  //送信ボタンクリック後の処理
  const handleSubmit = async () => {
    await axios.post(
      "https://xxxx.xxxx.xxxx", //メール送信APIのエンドポイントを記入
      {
        "subj": "Web からのフィードバック",
        "name": values.fbname,
        "email": values.fbemail,
        "body": values.fbmessage
      }
    );
    setValues({ isSubmitted: true });
  };
  //画面表示内容
  return values.isSubmitted ? (
    <section>
      {/* 送信済みフラグが true であれば 送信しました画面 を表示 */}
      <h2>フィードバック</h2>
      <p>フィードバックを送信しました。<br />貴重なご意見、ありがとうございました。</p>
    </section>
  ) : (
    <section>
      {/* 送信済みフラグが false であれば 入力画面 を表示 */}
      <h2>フィードバック</h2>
      <Box component="form" noValidate autoComplete="off">
        <ThemeProvider theme={theme}>
          <FormControl fullWidth>
            <TextField
              name="fbname"
              id="fbname"
              label="お名前"
              placeholder="お名前"
              variant="filled"
              margin="dense"
              size="small"
              InputProps={{ style: textfieldStyles }}
              InputLabelProps={{ style: textlabelStyles }}
              value={values.fbname}
              onChange={handleChange}
            />
            <TextField
              name="fbemail"
              id="fbemail"
              label="メールアドレス"
              placeholder="メールアドレス"
              variant="filled"
              margin="dense"
              size="small"
              InputProps={{ style: textfieldStyles }}
              InputLabelProps={{ style: textlabelStyles }}
              value={values.fbemail}
              onChange={handleChange}
              error={((regexpEmail.test(values.email) && (values.email)) || !(values.email)) ? false : true}
              helperText={((regexpEmail.test(values.fbemail) && (values.fbemail)) || !(values.fbemail)) ? null : "Incorrect Email address format."}
            />
            <TextField
              name="fbmessage"
              id="fbmessage"
              label="メッセージ"
              multiline
              minRows={3}
              placeholder="メッセージ"
              variant="filled"
              margin="dense"
              size="small"
              InputProps={{ style: textfieldStyles }}
              InputLabelProps={{ style: textlabelStyles }}
              value={values.fbmessage}
              onChange={handleChange}
            />
          </FormControl>
          <br />
          <br />
          <Button
            variant="contained"
            size="large"
            endIcon={<SendIcon />}
            disabled={(values.fbname && regexpEmail.test(values.fbemail) && values.fbmessage) ? false : true}
            onClick={handleSubmit}
          >
            フィードバックを送る
          </Button>
        </ThemeProvider>
      </Box>
    </section>
  );
};

export default Feedback;

コード解説

  • <TextField> タグで各入力用テキストフィールドをつくります。MUI 独特の属性があります。
    メールアドレスについては入力内容のチェックが入っており、一般的なメールアドレスの形式かどうかをチェックします。もしメールアドレスの形式でないデータの場合は、エラーメッセージが表示されます。
  • <Button> タグでボタンをつくります。こちらも MUI 独特の属性があります。endIcon= という属性を使用し、SendIcon という紙飛行機アイコンを文字の右側に配置しています。
    フィードバックを送るボタンは、名前、メッセージ、および正しいメールアドレスが入力されていることを条件にボタンが有効になります。ボタンが無効なときは色がグレーになっています。

  • 各入力項目は、value= で values というステートに格納されているデータを常に表示するようになっています。
    ユーザがフォームのテキストフィールドに何か入力すると、1 文字ごとの変更の度に onChange= に指定されている handleChange 関数を呼び出します。handleChange 関数は、入力されたデータを values ステートに格納する動きをします。それにより、ユーザが入力したデータが handleChange 関数を経由して、常に最新の入力データがテキストフィールドに表示されることになります。当たり前のようで当たり前でない、でも理に適った不思議な動きをします。
    このサンプルコードで記載されている handleChange 関数はチェックボックスのフォームでも使用できるようになっており、非常に汎用的にできています。他でも使い回しが可能です。
  • フィードバックを送るボタンを押すと、onClick= に定義されている handleSubmit 関数を呼び出します。handleSubmit 関数は、その時点で values ステートに格納されているユーザの入力データをメール送信 API に送ります。そのときに使用するモジュールは axios を使用します。
  • このように、フォームの入力データをフォームの外側にある変数(ステート)で完全に管理し、送る等のアクションを起こすときにはステートをそのまま送ってあげればよい、というつくりは「Controlled」と呼ばれます。このようなフォームを、Controlled form、管理されたフォームと呼びます。
  • handleSubmit 関数は、もう 1 つ仕事をします。メール送信済みフラグ values.isSubmitted を true に変えます。そうすると、return values.isSubmitted ? ( で記述されている if 条件分岐に従い、入力フォーム画面が「フィードバックを送信しました」画面に変わります。

  • そのほか、MUI のモジュールとして以下を使用しています。全て見た目を整えるためのものです。
    <Box> : 四角の表示枠をつくる。このフィードバック欄全体が Box で囲まれている。
    <FormControl> : フォームの設定をする。ここでは、このタグで囲まれた <TextField> の横幅を <Box> 内でフルサイズにするため使用。これがないと、自動で <TextField> の横幅がバラバラになってしまい、見た目がかっこ悪くなる。
    <ThemeProvider> : 見た目を変更するために使用。特にフォームのパーツで使われている文字のフォントを変更しようと思ったら、このモジュールがないと変更できなかった。

管理されたフォームについて

管理されたフォームについて、突っ込んで解説します。

handleChange 関数とは

コード解説の中で、handleChange という関数が登場しました。これは、フォームで使われている入力値の管理をするために重要な働きをする関数で、以下のような動きをします。

この処理は一瞬なので、裏でこんなことをしているなんて画面からはわかりません。
実は、1文字変更する毎にこのサイクルが回っています。
実際にはフォーム入力欄は1つではないことが多いので、管理する state が多くなります。state 代入関数や handleChange 関数も必然的に多くなるため、フォーム入力欄が多くなればなるほどコードが複雑になります。
コード効率化のため、入力値を管理する state を配列にして複数の入力値を1つの state にまとめ、handleChange 関数も1つにしてしまうのがテクニックです。(本記事のコードがそうなっています)

管理されたフォームにするメリット

React でフォームを作るときに、なぜわざわざこのような回りくどい処理をさせるのでしょうか。

それは、以下のようなメリットがあるからです。

画面表示値と、React管理化の変数 (state) を常時同期させることにより、

  1. 他の処理が、その処理実行時点で state に格納されている値を参照し自由に使うことができる。(メール送信のボタンはまさにこれを活用)
  2. state の値に変更があったことをトリガーに、他の処理を呼び出すことができる。(useEffect を使用、本記事では使用例なし)
単にフォームを成立させるだけではなく、入力値の変更によって「ちょっとした画面エフェクト」や「データチェック・加工」などをリアルタイムに実行できるようになり、画面開発の幅が広がります!

メール送信 API

メール送信画面(フロントエンド)からメールを送信するためには、メール送信 API(バックエンド)が必要です。ここでは AWS で構築するサンプルを紹介します。

以下のアーキテクチャになります。何の変哲もありません。

一応、誰でも送信できないように、Amazon API Gateway に CORS の設定はドメイン指定で入れておきます。実装は以下の AWS CloudFormation テンプレートで行います。パラメータに、アクセスを許可したいドメイン名、サブドメイン名を入れて下さい。

Amazon API Gateway はフロントエンドからメール件名、お名前、メールアドレス、メッセージのデータを受け取り、AWS Lambda 関数がメールテンプレートにデータを埋め込んで Amazon SES にメール送信命令を出します。

Amazon SES のサンドボックスは解除済み、送信元メールアドレスが Amazon SES の信頼済みアイデンティティに登録されていることが前提となります。送信元/送信先メールアドレスは AWS CloudFormation テンプレートのパラメータで指定します。

この AWS CloudFormation テンプレートで作成された Amazon API Gateway (HTTP API) のエンドポイントを、上述 React コード内で axios での API 呼出先として埋め込みます。エンドポイント URL は出力タブに表示されるようになっています。

AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates an API Gateway, a Lambda function and relevant IAM roles for sending emails via SES.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SystemName:
    Type: String
    Description: System sub name of example.
    Default: xxxxx
    MaxLength: 10
    MinLength: 1

  DomainName:
    Type: String
    Description: Domain name for URL.
    Default: example.com
    MaxLength: 40
    MinLength: 5
    AllowedPattern: "[^\\s@]+\\.[^\\s@]+"

  SubDomainName:
    Type: String
    Description: Sub domain name for URL.
    Default: xxxxx
    MaxLength: 20
    MinLength: 1

  FeedbackSenderEmail:
    Type: String
    Description: E-mail address that sends feedback emails.
    Default: sender@example.com
    MaxLength: 100
    MinLength: 5
    AllowedPattern: "[^\\s@]+@[^\\s@]+\\.[^\\s@]+"

  FeedbackReceiverEmail:
    Type: String
    Description: E-mail address that receives feedback emails.
    Default: receiver@example.com
    MaxLength: 100
    MinLength: 5
    AllowedPattern: "[^\\s@]+@[^\\s@]+\\.[^\\s@]+"

# ------------------------------------------------------------#
# Lambda
# ------------------------------------------------------------#
  LambdaSendFeedbackByEmail:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub example-SendFeedbackByEmail-${SystemName}
      Description: !Sub Lambda Function to send user's feedback from web site for example-${SystemName}
      Runtime: python3.9
      Timeout: 3
      MemorySize: 128
      Role: !GetAtt LambdaSesInvocationRole.Arn
      Handler: index.lambda_handler
      Tags:
        - Key: Cost
          Value: !Sub example-${SystemName}
      Code:
        ZipFile: !Sub |
          import json
          import datetime
          import boto3
          def datetimeconverter(o):
            if isinstance(o, datetime.datetime):
              return str(o)
          def lambda_handler(event, context):
            try:
              # Define Variables
              ses = boto3.client('ses',region_name='${AWS::Region}')
              d = json.loads(event['body'])
              print({"ReceivedData": d})
              category = d['category']
              RECEIVERS = []
              RECEIVERS.append('${FeedbackReceiverEmail}')
              SENDER = '${FeedbackSenderEmail}'
              # Send Email
              res = ses.send_email(
                Destination={ 'ToAddresses': RECEIVERS },
                Message={
                  'Body': {
                    'Text': {
                      'Charset': 'UTF-8',
                      'Data': 'NAME: ' + d['name'] + '\nMAILADDRESS: ' + d['email'] + '\n\nMESSAGE:\n' + d['body']
                    }
                  },
                  'Subject': {
                    'Charset': 'UTF-8',
                    'Data': d['subj'] + ' (${SubDomainName}.${DomainName})'
                  }
                },
                Source=SENDER,
                ReplyToAddresses=[d['email']]
              )
            except Exception as e:
              print(e)
              return {
                'isBase64Encoded': False,
                'statusCode': 200,
                'body': str(e)
              }
            else:
              return {
                'isBase64Encoded': False,
                'statusCode': 200,
                'body': json.dumps(res, indent=4, default=datetimeconverter)
              }

# ------------------------------------------------------------#
# API Gateway
# ------------------------------------------------------------#
  HttpApiSendFeedbackByEmail:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: !Sub example-SendFeedbackByEmail-${SystemName}
      Description: !Sub HTTP API to send user's feedback from web site for example-${SystemName}
      ProtocolType: HTTP
      CorsConfiguration:
        AllowCredentials: false
        AllowHeaders:
          - "*"
        AllowMethods:
          - POST
          - OPTIONS
        AllowOrigins:
          - !Sub https://${SubDomainName}.${DomainName}
        ExposeHeaders:
          - "*"
        MaxAge: 600
      DisableExecuteApiEndpoint: false
      Tags:
        Cost: !Sub example-${SystemName}
  HttpApiIntegrationSendFeedbackByEmail:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref HttpApiSendFeedbackByEmail
      IntegrationMethod: POST
      IntegrationType: AWS_PROXY
      IntegrationUri: !GetAtt LambdaSendFeedbackByEmail.Arn
      CredentialsArn: !GetAtt ApiGatewayLambdaInvocationRole.Arn
      PayloadFormatVersion: 2.0
      TimeoutInMillis: 5000
  HttpApiRouteSendFeedbackByEmail:
    Type: AWS::ApiGatewayV2::Route
    Properties: 
      ApiId: !Ref HttpApiSendFeedbackByEmail
      RouteKey: POST /SendFeedbackByEmail
      Target: !Sub integrations/${HttpApiIntegrationSendFeedbackByEmail}
  HttpApiStageSendFeedbackByEmail:
    Type: AWS::ApiGatewayV2::Stage
    Properties:
      ApiId: !Ref HttpApiSendFeedbackByEmail
      AutoDeploy: true
      StageName: $default

# ------------------------------------------------------------#
# Lambda SES Invocation Role (IAM)
# ------------------------------------------------------------#
  LambdaSesInvocationRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub example-SesAccess-${SystemName}
      Description: This role allows Lambda functions to access SES.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSESFullAccess
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicInvocationRole
        - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess

# ------------------------------------------------------------#
# API Gateway Lambda Invocation Role (IAM)
# ------------------------------------------------------------#
  ApiGatewayLambdaInvocationRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub example-ApiGatewayLambdaInvocation-${SystemName}
      Description: This role allows API Gateways to invoke Lambda functions.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - apigateway.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaRole
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
        - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
# API Gateway
  APIGatewayEndpointSendFeedbackByEmail:
    Value:
      !Join
        - ""
        - - !GetAtt HttpApiSendFeedbackByEmail.ApiEndpoint
          - /SendFeedbackByEmail

まとめ

いかがでしたでしょうか?

WEB メールフォームを作成するにあたり、必要なフロントエンド画面を React と MUI で作り込み、AWS でバックエンドを作って組み合わせる構成を超ざっくりと、一部だけ突っ込んで紹介いたしました。MUI は複雑な CSS 無しでかっこいい画面が簡単につくれるので、非常にお勧めです。

本記事が皆様のお役に立てれば幸いです。

タイトルとURLをコピーしました