📧

Next.jsでお問い合わせフォーム(HyperForm)

2023/09/07に公開

はじめに

この記事では Next.js で簡易的なお問い合わせフォームを作成する方法を紹介します。
バックエンドのメール送信機能については HyperForm を使用し、フロントエンド側の開発に焦点を当てたものになっています。

https://hyperform.jp/

使用技術

その他

対象読者

  • Next.js で簡易的なお問い合わせフォームを作る方法を知りたい方
  • React Hook FormValibot の組み合わせで実装したい方
  • バックエンド側のメール送信機能を作るのが面倒なのでヘッドレスフォームに任せて実装したい方

前提条件

  • Next.jsTypeScriptTailwind CSS のセットアップが完了しているものとして話を進めております。
  • スタイリングには Tailwind CSS を使用しております。
  • Node.js のパッケージマネージャーに yarn を使用しております。
  • HTTPクライアントには axios を使用しております。
  • react-hot-toastを使用し、ユーザー側に送信の成功や失敗のメッセージ表示を簡易的に済ませております。

完成イメージ

  • 名前 メールアドレス メッセージ の送信が可能
  • 各項目は必須項目でバリデーション時にはエラーメッセージを表示
  • 各項目に文字数制限
  • メールアドレス の形式のみ入力可能
  • メッセージは300文字以内でユーザーに入力文字数が可視化されるようにカウンター数値を設置
  • 送信中なのがわかるようにスピナー表示
  • 送信の完了と失敗時にトーストでメッセージ表示

準備

HyperFormの使用

  1. HyperFormのホームページからアカウントの作成を行います。
  2. 自動的に作成される カスタムURL を確認します。このURL内にユニークな フォームID が付与されています。このフォームIDを下記の形式のURLとして使用します。
https://hyperform.jp/api/async/{your-form-id}/complete

下記が公式ドキュメントの詳細です↓
https://marsh-metatarsal-460.notion.site/API-c1f2203ff6244e4fa8d4a55f65ef4109

  1. 環境変数としてセットしておきます。
.env.local
NEXT_PUBLIC_HYPERFORM_URL="https://hyperform.jp/api/async/{your-form-id}/complete"

ライブラリのインストール

プロジェクトにライブラリのインストールを行います。

terminal
yarn add react-hook-form @hookform/resolvers valibot axios react-hot-toast
@hookform/resolvers とは?

@hookform/resolversは、react-hook-formライブラリで使用できる各種バリデーションスキーマライブラリ(例:YupZodSuperstructなど)と統合を簡単に行うためのパッケージです。
※この記事では Valibot を使用しております。

このライブラリは、指定されたスキーマに基づいてバリデーションを実行し、react-hook-formが解釈できるエラーメッセージやバリデーションの結果を生成します

@hookform/resolversを使うことで、豊富なバリデーションルールを持つ外部のライブラリとreact-hook-formを簡単に連携できます。これはコードの再利用性を高め、バリデーションロジックのメンテナンスを容易にする大きな利点です。

下記は公式ドキュメントのサンプルコードです↓
https://react-hook-form.com/docs/useform#resolver

バリデーションのスキーマ定義

バリデーション用のスキーマを定義しておきます。

src/schema/contact.ts
import * as v from "valibot"

const nameSchema = v.pipe(
  v.string(),
  v.minLength(1, "This field is required."),
  v.maxLength(20, "Please enter no more than 20 characters.")
)

const emailSchema = v.pipe(
  v.string(),
  v.minLength(1, "This field is required."),
  v.maxLength(255, "Please enter no more than 255 characters."),
  v.email("Please enter a valid email address.")
)
const messageSchema = v.pipe(
  v.string(),
  v.minLength(1, "This field is required."),
  v.maxLength(300, "Please enter no more than 300 characters.")
)

export const ContactSchema = v.object({
  name: nameSchema,
  email: emailSchema,
  message: messageSchema,
})

export type ContactType = v.InferOutput<typeof ContactSchema>

react-hot-toastの設定

プロジェクトで react-hot-toast を使用できるようにしておきます。

src/app/layout.tsx
// --- 略 ---

+import { Toaster } from "react-hot-toast"

// --- 略 ---

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body className={inter.className}>
        {children}
+       <Toaster />
      </body>
    </html>
  )
}

Spinnerコンポーネントの作成

フォーム内容の送信が完了するまでに表示するSpinnerコンポーネントを作成しておきます。

src/components/Spinner.tsx
export const Spinner = () => (
  <div className=" mx-auto h-5 w-5 animate-spin rounded-full border-2 border-gray-200 border-t-transparent"></div>
)

フォーム画面の作成

最後にフォーム画面を作成します。下記がコード全体です。
このフォーム用のコンポーネントを app ディレクトリ配下の page.tsx で読み込んで使用します。

src/components/ContactForm.tsx
"use client"

import { valibotResolver } from "@hookform/resolvers/valibot"
import axios from "axios"
import { useForm } from "react-hook-form"
import { toast } from "react-hot-toast"

import { ContactSchema, ContactType } from "@/schema/contact"

import { Spinner } from "./ Spinner"

export const ContactForm = () => {
  const {
    register,
    handleSubmit,
    reset,
    watch,
    formState: { errors, isValid, isSubmitting },
  } = useForm<ContactType>({
    mode: "onBlur",
    resolver: valibotResolver(ContactSchema),
  })

  const messageValue = watch("message", "")
  const messageLength = messageValue.length

  const onSubmit = handleSubmit(async (data) => {
    try {
      await axios.post(process.env.NEXT_PUBLIC_HYPERFORM_URL as string, data)
      toast.success(
        "送信が完了いたしました。\n自動返信メールをお送りしておりますのでご確認をお願いいたします。",
      )
      reset()
    } catch (error) {
      console.error("Form submit error", error)
      toast.error("送信時にエラーが発生しました。\n恐れ入りますが後でもう一度お試しください。")
    }
  })

  return (
    <form
      method="post"
      onSubmit={onSubmit}
      className="flex w-[300px] flex-col items-center justify-center gap-3"
    >
      <div className="w-full">
        <label htmlFor="name" className="text-sm text-gray-600">
          お名前
        </label>
        <input
          type="text"
          id="name"
          {...register("name")}
          placeholder="山田 太郎"
          className="w-full border p-3 shadow hover:border-gray-400"
        />
        {errors.name && (
          <span className="self-start text-xs text-red-500">{errors.name.message}</span>
        )}
      </div>
      <div className="w-full">
        <label htmlFor="email" className="text-sm text-gray-600">
          メールアドレス
        </label>
        <input
          type="text"
          id="email"
          {...register("email")}
          placeholder="mail@example.com"
          className="w-full border p-3 shadow hover:border-gray-400"
        />
        {errors.email && (
          <span className="self-start text-xs text-red-500">{errors.email.message}</span>
        )}
      </div>
      <div className="w-full">
        <label htmlFor="message" className="text-sm text-gray-600">
          メッセージ
        </label>
        <textarea
          id="message"
          {...register("message")}
          placeholder="お問い合わせ内容を入力してください"
          rows={6}
          className="w-full border p-3 shadow hover:border-gray-400"
        ></textarea>
        <div className="pr-1 text-right text-xs text-gray-400">{messageLength}/300</div>
        {errors.message && (
          <span className="self-start text-xs text-red-500">{errors.message.message}</span>
        )}
      </div>
      <button
        type="submit"
        disabled={!isValid || isSubmitting}
        className={`w-full rounded bg-lime-600 p-3 text-white transition ${
          !isValid || isSubmitting ? "cursor-not-allowed opacity-60" : "hover:bg-lime-700"
        }`}
      >
        {isSubmitting ? <Spinner /> : "送信"}
      </button>
    </form>
  )
}
use client の宣言について

react-hook-form で フォームの状態管理を行うので、 Server Component ではなく Client Component として使用します。Client Component として使用する場合は先頭にuse client の宣言が必要です。
https://nextjs.org/docs/app/building-your-application/rendering/client-components

useForm について

useForm

まず、useForm は、react-hook-form ライブラリの主要なフックです。このフックを使って、フォームの状態やメソッドを管理・制御します。

const {
    register,
    handleSubmit,
    reset,
    watch,
    formState: { errors: formatError, isValid, isSubmitting },
} = useForm<ContactType>({
    mode: "onBlur",
    resolver: valibotResolver(ContactSchema),
});

上記のコードでは、useForm からいくつかの機能や状態を分割代入して取得しています。

register

register は、フォームの各インプット要素に関連付ける関数です。これを使うことで、そのインプット要素の値や状態を react-hook-form で監視・管理することができるようになります。

例:

<input {...register("name")} />

handleSubmit

handleSubmit は、フォームの送信時に呼び出される関数をラップする関数です。これを利用することで、フォームが正しくバリデーションされた後に、指定したコールバック関数(上記コードでは onSubmit)が呼び出されます。

reset

reset は、フォームの全てのフィールドを初期状態に戻す関数です。上記のコードでは、フォームの送信に成功した後にこれを使用して、フォームのフィールドをクリアしています。

watch

watch は、指定したフォームフィールドの値をリアルタイムで監視する関数です。
第一引数には監視したいフィールドの名前(string)を指定します。
第二引数にはそのフィールドのデフォルト値を指定します。

const messageValue = watch("message", "");

上記のコードでは、"message" フィールドの値を監視しており、その値が messageValue に格納されます。今回はデフォルト値は不要なため空文字にしています。

messageLength

messageLength は、messageValue の文字数を取得するための変数です。これを使用することで、テキストエリアの文字数をリアルタイムでユーザーに表示することができます。

formState

formState は、フォーム全体の状態を含むオブジェクトです。この中から、エラー情報 (errors)、フォームが有効かどうかの状態 (isValid)、及びフォームが送信中かどうかの状態 (isSubmitting) を取得しています。

  • errors: バリデーションエラー情報を含むオブジェクトです。
  • isValid: フォームがバリデーションに通っているかどうかのブール値です。
  • isSubmitting: フォームが送信中かどうかのブール値です。

最後に、useForm のオプションとして、mode: "onBlur"resolver: valibotResolver(ContactSchema) を指定しています。

  • mode: "onBlur": このモードは、ユーザーがインプットフィールドからフォーカスを外した時にバリデーションをトリガーすることを指定します。
  • resolver: これは、バリデーションロジックを提供する関数を指定するためのオプションです。ここでは Valibot で定義したバリデーションスキーマを指定したいので 、valibotResolverContactSchema を使って、フォームのバリデーションを行っています。
onSubmit関数 について

onSubmit関数の主な役割は、フォームデータをサーバーに送信する処理と、その結果に応じて適切なフィードバックをユーザーに提供することです。

const onSubmit = handleSubmit(async (data) => {
    try {
      await axios.post(process.env.NEXT_PUBLIC_HYPERFORM_URL as string, data)
      toast.success(
        "送信が完了いたしました。\n自動返信メールをお送りしておりますのでご確認をお願いいたします。",
      )
      reset()
    } catch (error) {
      console.error("Form submit error", error)
      toast.error("送信時にエラーが発生しました。\n恐れ入りますが後でもう一度お試しください。")
    }
})

詳細な解説:

  1. const onSubmit = handleSubmit(async (data) => {...}:

    • onSubmitという名前の関数を定義しています。
    • handleSubmitreact-hook-formの関数で、この関数を通じて提供されたコールバック関数(この場合は非同期関数async (data) => {...})を呼び出します。data引数はフォーム内のすべての入力値を保持するオブジェクトです。
  2. try {...}:

    • エラーが発生する可能性のあるコードをtryブロック内に配置します。もしエラーが発生すれば、直ちに対応するcatchブロックに制御が移ります。
  3. await axios.post(process.env.NEXT_PUBLIC_HYPERFORM_URL as string, data):

    • axios.postを使用して、データをサーバーに非同期的にPOST送信しています。
    • 送信先のURLは環境変数NEXT_PUBLIC_HYPERFORM_URLから取得しています。as string はTypeScriptの型アサーションで、この変数を文字列として扱うことを明示しています。
  1. toast.success(...):

    • フォームのデータが正常に送信された場合、ユーザーに成功メッセージを表示します。これはreact-hot-toasttoast関数を使用しています。
  2. reset():

    • フォームデータの送信が成功した後、reset関数を使用してフォームの入力を初期状態に戻します。
  3. catch (error) {...}:

    • 何らかのエラーが発生した場合(例: ネットワーク接続の問題、サーバーエラーなど)、catchブロックが実行されます。
  4. console.error("Form submit error", error):

    • 発生したエラーをコンソールに出力します。
  5. toast.error(...):

    • 発生したエラーに関する情報をユーザーに表示するために使用されます。

繰り返しになりますが、この関数全体の目的は、フォームデータをサーバーに送信し、その結果に基づいてユーザーに適切なフィードバックを提供することです。

return以降 について

Tailwind CSS でのスタイリングは適当です。

return (
    <form
      method="post"
      onSubmit={onSubmit}
      className="flex w-[300px] flex-col items-center justify-center gap-3"
    >
      <div className="w-full">
        ...
      </div>
      ...
      <button
        ...
      >
        {isSubmitting ? <Spinner /> : "送信"}
      </button>
    </form>
)
  1. <form>タグ:

    • method="post"は、このフォームのデータ送信方法がPOSTであることを示しています。
    • onSubmit={onSubmit}は、フォームが送信されるときにonSubmit関数が実行されることを指定しています。
  2. <input>タグ:

    • {...register("name")}{...register("email")}react-hook-formを使用して、このinputフィールドをフォームと関連付けるための構文です。
  3. <textarea>タグ:

    • こちらも{...register("message")}を使用して、react-hook-formとの連携を確立しています。
  4. <div className="...">{messageLength}/300</div>:

    • メッセージの入力文字数とその最大値(この場合は300)を表示するためのdiv要素です。
  5. <span className="...">{errors.name.message}</span>:

    • エラーメッセージを表示するためのspanタグです。
    • errors.name.messageは、nameフィールドのエラーメッセージを取得します。
    • errors.email.messageは、emailフィールドのエラーメッセージを取得します。
    • errors.message.messageは、messageフィールドのエラーメッセージを取得します。
  6. <button>タグ:

    • {isSubmitting ? <Spinner /> : "送信"}は、フォームの送信が進行中であるかどうかに基づいて、スピナーアイコンまたは"送信"というテキストを表示します。

このjsx(tsx)の構造は、シンプルなコンタクトフォームを提供するためのものです。各入力フィールドには対応するラベルがあり、ユーザーが入力したデータはonSubmit関数を通じてサーバーに送信されます。

まとめ

  • Next.js で、React Hook FormValibot の組み合わせでお問い合わせフォームを実装しました。
  • ヘッドレスフォーム に HyperForm を使用し、メール送信機能を実現しました。

HyperForm に関しては開発者の方の下記記事が参考になります。今回は HyperForm の機能について説明は省いていますが、お問い合わせフォームに必要とされる色々な機能を実現できそうなので気になる方はご参照ください。
https://zenn.dev/d0ne1s/articles/71043208001b61

Discussion