Nuxt.js(Composition API)で簡単お問い合わせフォーム実装

37 min read読了の目安(約33400字

今回の題材

Nuxt.js(Composition API) + TypeScript + Tailwind CSS を使って簡単なお問い合わせフォーム(レスポンシブ)を作ってみます。

目次

1. 前提の確認
2. 新規プロジェクトの作成
3. トップページの作成
4. お問い合わせページの土台を作成
5. お問い合わせフォームの <script> タグ内の実装
6. お問い合わせフォームの <template> タグ内の実装
7. コンポーネントの分割
8. フォームのバリデーションを追加
9. まとめ

完成コード

https://github.com/Shigeyuki-fukuda/nuxt-inquiry-form

完成アプリケーション

※サンプルがどんな画面か確認出来るようにNetlifyにデプロイしましたが、本番環境では http://localhost:3000/inquiry という お問い合わせを作成するAPIのエンドポイント が存在しないためエラーになりますが、表示確認に留めてエラーについてはスルーしてください🙏

https://nuxt-inquiry-form.netlify.app

1. 前提の確認

  • Mac環境であること
  • 何らかのプログラミング言語を触ったことがある人が対象
  • プログラミングをする上で最低限の環境が終わっていること(エディタ/ターミナルの設定ほか)
  • Node.js / Nodenvを利用出来る状態

2. 新規プロジェクトの作成

npx create-nuxt-app + プロジェクト名 をEnterしたら、それぞれの使用する技術要素を選択して、Enterします。

※注意点としては、ラジオボタンのような選択肢は space キーを入力して緑にラジオボタンが光って初めて選択状態になりますので、選択してからEnterを押すようにしてください。

$ npx create-nuxt-app nuxt-inquiry-form

create-nuxt-app v3.6.0
✨  Generating Nuxt.js project in nuxt-inquiry-form
? Project name: nuxt-inquiry-form
? Programming language: TypeScript
? Package manager: Npm
? UI framework: Tailwind CSS
? Nuxt.js modules: Axios - Promise based HTTP client
? Linting tools: ESLint, Prettier
? Testing framework: Jest
? Rendering mode: Single Page App
? Deployment target: Static (Static/Jamstack hosting)
? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Continuous integration: GitHub Actions (GitHub only)
? What is your GitHub username? GitHubに登録されているユーザー名
? Version control system: Git

ディレクトリを移動して確認していきます。

$ cd プロジェクト名

ローカルサーバーの起動を起動してみましょう。

$  npm run dev

> nuxt-inquiry-form@1.0.0 dev
> nuxt


   ╭───────────────────────────────────────────────────────╮
   │                                                       │
   │   Nuxt @ v2.15.4                                      │
   │                                                       │
   │   ▸ Environment: development                          │
   │   ▸ Rendering:   client-side                          │
   │   ▸ Target:      static                               │
   │                                                       │
   │   Listening: http://localhost:3000/                   │
   │                                                       │
   │   Tailwind Viewer: http://localhost:3000/_tailwind/   │
   │                                                       │
   ╰───────────────────────────────────────────────────────╯

ℹ Preparing project for development                                                                                                                                17:15:04
ℹ Initial build may take a while                                                                                                                                   17:15:04
ℹ Discovered Components: .nuxt/components/readme.md                                                                                                                17:15:04
✔ Builder initialized                                                                                                                                              17:15:04
✔ Nuxt files generated                                                                                                                                             17:15:04

✔ Client
  Compiled successfully in 8.83s

ℹ Waiting for file changes                                                                                                                                         17:15:14
ℹ Memory usage: 213 MB (RSS: 505 MB)                                                                                                                               17:15:14
ℹ Listening on: http://localhost:3000/                                                                                                                             17:15:14
No issues found.

http://localhost:3000/ にアクセスして以下の画面が表示出来ればOKです🙆‍♀️



今回のプロジェクトディレクトリで使用するNodeのバージョンも念のため指定しておきましょう。

nodenv local バージョン を実行すると実行ディレクトリに .node-version というファイルが作成されます。

$ nodenv local 15.12.0

3. トップページの作成

トップページがデフォルトのままだと味気ないので、お問い合わせページに飛ばすとこまで最低限実装してみます。

/pages/index.vue

Tailwind CSSを利用しているので、 <style></style> タグは消してOKです。
トップページはお問い合わせに飛ばすリンクだけ記述すればOKのため <script></script> タグも不要なので消してしまいましょう。
残るのは <template></template> タグのみです。
コードは以下の通りです。
簡素極まりないトップページですが、本来やりたいことがお問い合わせページの実装なのでご了承ください。

<template>
  <div class="m-auto flex justify-center min-h-screen items-center text-center bg-black">
    <div>
      <h1 class="block font-bold text-6xl text-gray-50">
        TOP PAGE
      </h1>
      <nuxt-link
        to="/inquiry"
        type="button"
        class="py-4 px-8 mt-8 text-2xl text-gray-50 bg-green-500 hover:bg-green-600 focus:ring-green-600 focus:ring-offset-green-600 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-full"
      >
        Enter
      </nuxt-link>
    </div>
  </div>
</template>

ブラウザで表示するとこんな感じになります。

4. お問い合わせページの土台を作成

/pages/inquiry/index.vue

それっぽい見た目だけ先に作っていきます。   
コンポーネントの分割やNuxtでの動的な処理の実装は後続のセクションで行います。

<template>
  <div class="flex items-center min-h-screen bg-black">
    <div class="container mx-auto">
      <div class="max-w-md mx-auto my-10 bg-white p-5 rounded-md shadow-sm">
        <div class="text-center">
          <h1
            class="my-3 text-3xl font-semibold text-gray-700"
          >
            お問い合わせ
          </h1>
        </div>
        <div class="m-7">
          <form>
            <div class="mb-6">
              <label
                for="name"
                class="block mb-2 text-sm text-gray-600"
              >
                お名前
                <span class="text-xs text-red-500">(必須)</span>
              </label>
              <input
                id="name"
                type="text"
                name="name"
                placeholder="お名前太郎"
                class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
              />
            </div>
            <div class="mb-6">
              <label
                for="email"
                class="block mb-2 text-sm text-gray-600"
              >
                メールアドレス
                <span class="text-xs text-red-500">(必須)</span>
              </label>
              <input
                id="email"
                type="email"
                name="email"
                placeholder="your@example.com"
                class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
              />
            </div>
            <div class="mb-6">
              <label
                for="phone"
                class="text-sm text-gray-600"
              >
                電話番号
              </label>
              <input
                id="phone"
                type="text"
                name="phone"
                placeholder="0312345678"
                class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
              />
            </div>
            <div class="mb-6">
              <label
                for="message"
                class="block mb-2 text-sm text-gray-600"
              >
                内容
                <span class="text-xs text-red-500">(必須)</span>
              </label>
              <textarea
                id="message"
                rows="5"
                name="message"
                placeholder="お問い合わせ内容です"
                class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
              >
              </textarea>
            </div>
            <div class="mb-6">
              <button
                type="submit"
                class="w-full px-3 py-4 font-bold text-white bg-green-500 rounded-md focus:bg-green-600 focus:outline-none"
              >
                送信する
              </button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>

一旦、見た目だけ作った完成形がこちらです。

※出典: こちらのフォームは tailwind-kit.comのUIキットのformの例をアレンジしたものになります📝

5. お問い合わせフォームの <script> タグ内の実装

ここから Nuxt.jsでComposition APIを利用したフォームのサンプルを作っていきます!
<script lang="ts"> タグを追加して実装を書いていく前にNuxt.jsでVue.jsのComposition APIを利用する際には @nuxtjs/composition-api をインストールする必要があるのでインストールと設定をしていきます。

$ npm i @nuxtjs/composition-api

nuxt.config.js

@nuxtjs/composition-api を利用するために buildModulesに@nuxtjs/composition-api を追加しましょう。

buildModules: [
  // https://go.nuxtjs.dev/typescript
  '@nuxt/typescript-build',
  // https://go.nuxtjs.dev/tailwindcss
  '@nuxtjs/tailwindcss',
  '@nuxtjs/composition-api/module', // ここを追加
],

※注意

nuxt@2.15.2 までは以下の記載方法で動くのですが、 nuxt@2.15.3以降 はすでに書いた例で buildModules の中に Composition APIのプラグインを書きましょう。

以前の書き方

buildModules: [
  // https://go.nuxtjs.dev/typescript
  '@nuxt/typescript-build',
  // https://go.nuxtjs.dev/tailwindcss
  '@nuxtjs/tailwindcss',
  '@nuxtjs/composition-api', // <= "/module" の部分が無くても nuxt@2.15.3 以前は動いた
],

refs: https://fuqda.hatenablog.com/entry/2021/04/14/173847

Composition APIで <script> タグ内の処理を書いてみる

@nuxtjs/composition-api のセットアップが完了したら、Composition APIで <script> タグの中身を書いていきましょう。

Composition APIで書く際の初期状態は下記のような形になりますが、ここから処理を追加していきます。

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'
export default defineComponent({
  setup() {
    return {}
  },
})
</script>

実際にお問い合わせフォームに必要な情報を追加した例が後述のコードです。

/pages/inquiry/index.vue

<script lang="ts">
import { defineComponent, useContext, ref } from '@nuxtjs/composition-api'
export default defineComponent({
  setup() {
    const { $axios } = useContext()
    const name = ref('')
    const email = ref('')
    const phone = ref('')
    const message = ref('')
    const isSubmited = ref(false)

    const onSubmit = async () => {
      return await $axios
        .post('/inquiry', {
          inquiry: {
            name,
            email,
            phone,
            message,
          },
        })
        .then(() => {
	  isSubmited.value = true
        })
        .catch(() => {})
    }

    return {
      name,
      email,
      phone,
      message,
      isSubmited,
      onSubmit,
    }
  },
})
</script>

Composition APIの詳細は公式ドキュメントを要参照ですが、簡単に解説していきます。

従来のVue.js 2.x系では Options API を使う形式に則って data オブジェクトを介してリアクティブなデータを制御していました。

今回使用する Vue.js 3.x系以降 では Composition API を使い setup 関数の中でリアクティブなデータや関数を定義します。
(今回は Nuxt.js から利用するため @nuxt.js/composition-api を使用して実装していきます)

下記のコードでは、まずはじめに @nuxtjs/composition-api から コンポーネント内で必要な関数をimportし、 setup 関数内で <template> で利用するリアクティブな値や関数の定義を行っています。

<script lang="ts">
import { defineComponent, useContext, ref } from '@nuxtjs/composition-api'

リアクティブな値の定義方法として、 refreactive がありますが、StringやBooleanなどプリミティブな値の定義をしたかったので、今回のコードでは ref でリアクティブな値を定義しました。

const name = ref('')
const email = ref('')
const phone = ref('')
const message = ref('')
const isSubmited = ref(false)

また、 reactive との違いなどについての解説は以下のスライドがわかりやすかったのでご参照ください🙋‍♀️

https://speakerdeck.com/kawamataryo/ref-vs-reactive-vue-composition-api-deep-in

今回はお問い合わせを送信した後に画面の表示を切り替えたいので、送信後にはバックエンドの処理は成功したということにして、フラグを切り替えています。

const onSubmit = async () => {
  return await $axios
    .post('/inquiry', {
      inquiry: {
        name,
        email,
        phone,
        message,
      },
    })
    .then(() => {
      isSubmited.value = true
    })
    .catch(() => {})
}

そして、最終的に return した値がリアクティブな値や関数として <template> 内で利用可能になります。

return {
  name,
  email,
  phone,
  message,
  isSubmited,
  onSubmit,
}

6. お問い合わせフォームの <template> タグ内の実装

/pages/inquiry/index.vue

追加した項目は主に各入力項目毎に v-model="name" のような属性の付与と、送信フラグによって表示を切り替える部分です。

<template>
  <div class="flex items-center min-h-screen bg-black">
    <div class="container mx-auto">
      <div class="max-w-md mx-auto my-10 bg-white p-5 rounded-md shadow-sm">
        <div class="text-center">
          <h1
            class="my-3 text-3xl font-semibold text-gray-700"
          >
            お問い合わせ
          </h1>
        </div>
        <div v-if="isSubmited" class="m-7 text-center">
          <h1>お問い合わせいただきありがとうございます。</h1>
          <h2>ご返信までしばらくお待ちください。</h2>
          <nuxt-link
            to="/"
            type="button"
            class="py-4 px-8 mt-8 text-lg text-gray-50 bg-green-500 hover:bg-green-600 focus:ring-green-600 focus:ring-offset-green-600 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-full"
          >
            TOPへ戻る
          </nuxt-link>
        </div>
        <div v-if="!isSubmited" class="m-7">
          <form>
            <div class="mb-6">
              <label
                for="name"
                class="block mb-2 text-sm text-gray-600"
              >
                お名前
                <span class="text-xs text-red-500">(必須)</span>
              </label>
              <input
                id="name"
                v-model="name"
                type="text"
                name="name"
                placeholder="お名前太郎"
                class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
              />
            </div>
            <div class="mb-6">
              <label
                for="email"
                class="block mb-2 text-sm text-gray-600"
              >
                メールアドレス
                <span class="text-xs text-red-500">(必須)</span>
              </label>
              <input
                id="email"
                v-model="email"
                type="email"
                name="email"
                placeholder="your@example.com"
                class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
              />
            </div>
            <div class="mb-6">
              <label
                for="phone"
                class="text-sm text-gray-600"
              >
                電話番号
              </label>
              <input
                id="phone"
                v-model="phone"
                type="text"
                name="phone"
                placeholder="0312345678"
                class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
              />
            </div>
            <div class="mb-6">
              <label
                for="message"
                class="block mb-2 text-sm text-gray-600"
              >
                内容
                <span class="text-xs text-red-500">(必須)</span>
              </label>
              <textarea
                id="message"
                v-model="message"
                rows="5"
                name="message"
                placeholder="お問い合わせ内容です"
                class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
              >
              </textarea>
            </div>
            <div class="mb-6">
              <button
                class="w-full px-3 py-4 font-bold text-white bg-green-500 rounded-md focus:bg-green-600 focus:outline-none"
                @click.prevent="onSubmit"
              >
                送信する
              </button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>

isSubmited が trueになった場合に お問い合わせありがとうございます の要素が表示されるようにしてあります。

<div v-if="isSubmited" class="m-7 text-center">
  <h1>お問い合わせいただきありがとうございます。</h1>
  <h2>ご返信までしばらくお待ちください。</h2>
  <nuxt-link
   to="/"
   type="button"
   class="py-4 px-8 mt-8 text-lg text-gray-50 bg-green-500 hover:bg-green-600 focus:ring-green-600 focus:ring-offset-green-600 transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-full"
   >
   TOPへ戻る
  </nuxt-link>
</div>

送信前

送信後

7. コンポーネントの分割

このままだと見通しが悪い & コンポーネントの粒度が粗いので分割していきましょう。
分割結果は以下の通りです。

/pages/inquiry/index.vue

<template>
  <InquiryForm></InquiryForm>
</template>

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'
import InquiryForm from '@/components/inquiry/Form.vue'

export default defineComponent({
  components: {
    InquiryForm,
  },
})
</script>

/components/inquiry/Form.vue

お問い合わせフォームの全体を表示するコンポーネントとなっています。
各部品についても順々に見ていきます。

<template>
  <div class="flex items-center min-h-screen bg-black">
    <div class="container mx-auto">
      <div class="max-w-md mx-auto my-10 bg-white p-5 rounded-md shadow-sm">
        <Headline />
        <Submitted v-if="isSubmited" />
        <div v-if="!isSubmited" class="m-7">
          <form>
            <div class="mb-12">
              <Label>
                お名前 <span class="text-xs text-red-500">(必須)</span>
              </Label>
              <NameInput
                v-model="name"
                :type="'text'"
                :placeholder="'お名前太郎'"
              />
            </div>
            <div class="mb-12">
              <Label>
                メールアドレス <span class="text-xs text-red-500">(必須)</span>
              </Label>
              <EmailInput
                v-model="email"
                :type="'email'"
                :placeholder="'your@example.com'"
              />
            </div>
            <div class="mb-12">
              <Label> 電話番号 </Label>
              <PhoneInput
                v-model="phone"
                :type="'tel'"
                :placeholder="'0312345678'"
              />
            </div>
            <div class="mb-12">
              <Label>
                内容 <span class="text-xs text-red-500">(必須)</span>
              </Label>
              <MessageInput
                v-model="message"
                :placeholder="'お問い合わせ内容です'"
              />
            </div>
            <div class="mb-12">
              <Button @click="onSubmit">送信する</Button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, useContext, ref } from '@nuxtjs/composition-api'
import Headline from '@/components/inquiry/Headline.vue'
import Submitted from '@/components/inquiry/Submitted.vue'
import Label from '@/components/inquiry/Label.vue'
import NameInput from '@/components/inquiry/NameInput.vue'
import EmailInput from '@/components/inquiry/EmailInput.vue'
import PhoneInput from '@/components/inquiry/PhoneInput.vue'
import MessageInput from '@/components/inquiry/MessageInput.vue'
import Button from '@/components/inquiry/Button.vue'

export default defineComponent({
  name: 'Form',
  components: {
    Headline,
    Submitted,
    Label,
    NameInput,
    EmailInput,
    PhoneInput,
    MessageInput,
    Button,
  },
  setup() {
    const { $axios } = useContext()
    const name = ref('')
    const email = ref('')
    const phone = ref('')
    const message = ref('')
    const isSubmited = ref(false)

    const onSubmit = async () => {
      return await $axios
        .post('/inquiry', {
          contact: {
            name,
            email,
            phone,
            message,
          },
        })
        .then(() => {
          isSubmited.value = true
        })
        .catch(() => {})
    }

    return {
      name,
      email,
      phone,
      message,
      isSubmited,
      onSubmit,
    }
  },
})
</script>

/components/inquiry/Headline.vue

お問い合わせの見出し部分のコンポーネントです。
フォーム全体のコンポーネントをすっきり書きたかったので分割したものの、これはコンポーネントにしないでベタ書きしても良かったかも... 😅

<template>
  <div class="text-center">
    <h1 class="my-3 text-3xl font-semibold text-gray-700">
      お問い合わせ
    </h1>
  </div>
</template>

<script>
import { defineComponent } from '@vue/composition-api'

export default defineComponent({
  name: 'Headline',
})
</script>

/components/inquiry/Submitted.vue

お問い合わせを送信した後に画面を切り替えた時の表示部分のコンポーネントです。

<template>
  <div class="m-7 text-center">
    <h1>お問い合わせいただきありがとうございます。</h1>
    <h2>ご返信までしばらくお待ちください。</h2>
    <nuxt-link
      to="/"
      type="button"
      class="py-4 px-8 mt-8 text-lg text-gray-50 bg-green-500 hover:bg-green-600 focus:ring-green-600 focus:ring-offset-green-600 transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-full"
    >
      TOPへ戻る
    </nuxt-link>
  </div>
</template>

<script lang="ts">
import { defineComponent } from '@vue/composition-api'

export default defineComponent({
  name: 'Submitted',
})
</script>

/components/inquiry/Label.vue

お問い合わせフォームの各項目名を表示するためのコンポーネントです。

<template>
  <label class="block mb-2 text-sm text-gray-600">
    <slot />
  </label>
</template>

<script>
import { defineComponent } from '@vue/composition-api'

export default defineComponent({
  name: 'Label',
})
</script>

/components/inquiry/NameInput.vue

名前入力欄のコンポーネントです。

<template>
  <input
    :type="type"
    :placeholder="placeholder"
    class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
    @input="handleInput"
  />
</template>

<script lang="ts">
import { defineComponent, SetupContext } from '@vue/composition-api'

export default defineComponent({
  name: 'NameInput',
  props: {
    type: { type: String, required: true },
    placeholder: { type: String, required: false, default: 'Nuxt太郎' },
  },
  setup(_, context: SetupContext) {
    const handleInput = (e: Event) => {
      const target = e.target as HTMLInputElement
      context.emit('input', target.value)
    }
    return {
      handleInput,
    }
  },
})
</script>

/components/inquiry/EmailInput.vue

メールアドレス入力欄のコンポーネントです。

<template>
  <input
    :type="type"
    :placeholder="placeholder"
    class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
    @input="handleInput"
  />
</template>

<script lang="ts">
import { defineComponent, SetupContext } from '@vue/composition-api'

export default defineComponent({
  name: 'EmailInput',
  props: {
    type: { type: String, required: true },
    placeholder: { type: String, required: false, default: 'nuxt@example.com' },
  },
  setup(_, context: SetupContext) {
    const handleInput = (e: Event) => {
      const target = e.target as HTMLInputElement
      context.emit('input', target.value)
    }
    return {
      handleInput,
    }
  },
})
</script>

/components/inquiry/PhoneInput.vue

電話番号入力欄のコンポーネントです。

<template>
  <input
    :type="type"
    :placeholder="placeholder"
    class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
    @input="handleInput"
  />
</template>

<script lang="ts">
import { defineComponent, SetupContext } from '@vue/composition-api'

export default defineComponent({
  name: 'PhoneInput',
  props: {
    type: { type: String, required: true },
    placeholder: { type: String, required: false, default: '00000000000' },
  },
  setup(_, context: SetupContext) {
    const handleInput = (e: Event) => {
      const target = e.target as HTMLInputElement
      context.emit('input', target.value)
    }
    return {
      handleInput,
    }
  },
})
</script>

/components/inquiry/MessageInput.vue

お問い合わせ内容の本文入力欄のコンポーネントです。

<template>
  <textarea
    rows="5"
    :placeholder="placeholder"
    class="w-full px-3 py-2 placeholder-gray-300 border border-gray-300 rounded-md focus:outline-none focus:ring focus:ring-indigo-100 focus:border-indigo-300"
    @input="handleInput"
  />
</template>

<script lang="ts">
import { defineComponent, SetupContext } from '@vue/composition-api'

export default defineComponent({
  name: 'MessageInput',
  props: {
    placeholder: { type: String, required: false, default: '' },
  },
  setup(_, context: SetupContext) {
    const handleInput = (e: Event) => {
      const target = e.target as HTMLInputElement
      context.emit('input', target.value)
    }
    return {
      handleInput,
    }
  },
})
</script>

8. フォームのバリデーションを追加

ここまででフォームのデータを送信して、送信後の画面に切り替える部分の実装が完了しました。
次にフォームにバリデーションを追加してみましょう。

vue-composable の追加

まず最初にバリデーション用のライブラリを追加します。

$ npm i vue-composable

https://www.npmjs.com/package/vue-composable

/components/inquiry/Form.vue

useValidation を新しく入れたライブラリからimportします。

<script lang="ts">
import { defineComponent, useContext, ref } from '@nuxtjs/composition-api'
import { useValidation } from 'vue-composable' // <= ここにimport文を追加

以下は、各項目に必須バリデーションとフォーマットバリデーションをかけるための定義です。

setup() {
  // 中略
  const required = (value: string | null | undefined): Boolean => !!value
  const phoneNumberFormat = (value: string): Boolean =>
      value ? !!value.match(/\d{2,3}-\d{1,4}-\d{4}$/) : true
  const params = useValidation({
    name: {
      $value: name,
      required,
      $message: 'お名前を入力してください',
    },
    email: {
      $value: email,
      required,
      $message: 'メールアドレスを入力してください',
    },
    phone: {
      $value: phone,
      format: {
        $validator: phoneNumberFormat,
        $message: '電話番号の形式が不正です',
      },
    },
    message: {
      $value: message,
      required,
      $message: 'お問い合わせ内容を入力してください',
    },
  })
  
  const onSubmit = async (): Promise<any> => {
    if (!params.$anyInvalid) {
      return await $axios
        .post('/inquiry', {
          inquiry: {
	    name,
            email,
            phone,
            message,
	  },
	})
        .then(() => {
          isSubmited.value = true
        })
        .catch(() => {})
    } else {
      return await params.$touch()  // $touch()でバリデーション定義したパラメーターのparams内の各項目に対してバリデーション実行を行う
    }
  }

  return {
    name,
    email,
    phone,
    message,
    isSubmited,
    params, // ここでreturnすることでバリデーション定義を有効化
    onSubmit,
  }

エラー用コンポーネント(/components/inquiry/Error.vue)の追加

シンプルに親から受け取ったメッセージを表示するだけのコンポーネントです。
なお、今回は一つの項目に複数のバリデーションエラーが起こることがないので単一のメッセージのみを表示する前提のコンポーネントにしています。

<template>
  <p class="text-xs text-red-500 mt-3">{{ message }}</p>
</template>

<script lang="ts">
import { defineComponent } from '@vue/composition-api'

export default defineComponent({
  name: 'Error',
  props: { message: { type: String, required: true } },
})
</script>

/components/inquiry/Form.vue への反映

利用イメージがこちら。

<Error
 v-if="params.name.$dirty && params.name.$anyInvalid"
  :message="params.name.$message"
/>

上記をFormコンポーネントに適用後の全体像が以下の通りです。

<template>
  <div class="flex items-center min-h-screen bg-black">
    <div class="container mx-auto">
      <div class="max-w-md mx-auto my-10 bg-white p-5 rounded-md shadow-sm">
        <Headline />
        <Submitted v-if="isSubmited" />
        <div v-if="!isSubmited" class="m-7">
          <form>
            <div class="mb-12">
              <Label>
                お名前 <span class="text-xs text-red-500">(必須)</span>
              </Label>
              <NameInput
                v-model="name"
                :type="'text'"
                :placeholder="'お名前太郎'"
              />
              <Error
                v-if="params.name.$dirty && params.name.$anyInvalid"
                :message="params.name.$message"
              />
            </div>
            <div class="mb-12">
              <Label>
                メールアドレス <span class="text-xs text-red-500">(必須)</span>
              </Label>
              <EmailInput
                v-model="email"
                :type="'email'"
                :placeholder="'your@example.com'"
              />
              <Error
                v-if="params.email.$dirty && params.email.$anyInvalid"
                :message="params.email.$message"
              />
            </div>
            <div class="mb-12">
              <Label> 電話番号 </Label>
              <PhoneInput
                v-model="phone"
                :type="'tel'"
                :placeholder="'0312345678'"
              />
              <Error
                v-if="params.phone.$dirty && params.phone.$anyInvalid"
                :message="params.phone.format.$message"
              />
            </div>
            <div class="mb-12">
              <Label>
                内容 <span class="text-xs text-red-500">(必須)</span>
              </Label>
              <MessageInput
                v-model="message"
                :placeholder="'お問い合わせ内容です'"
              />
              <Error
                v-if="params.message.$dirty && params.message.$anyInvalid"
                :message="params.message.$message"
              />
            </div>
            <div class="mb-12">
              <Button @click="onSubmit">送信する</Button>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, useContext, ref } from '@nuxtjs/composition-api'
import { useValidation } from 'vue-composable'
import Headline from '@/components/inquiry/Headline.vue'
import Submitted from '@/components/inquiry/Submitted.vue'
import Label from '@/components/inquiry/Label.vue'
import NameInput from '@/components/inquiry/NameInput.vue'
import EmailInput from '@/components/inquiry/EmailInput.vue'
import PhoneInput from '@/components/inquiry/PhoneInput.vue'
import MessageInput from '@/components/inquiry/MessageInput.vue'
import Button from '@/components/inquiry/Button.vue'
import Error from '@/components/inquiry/Error.vue'

export default defineComponent({
  name: 'Form',
  components: {
    Headline,
    Submitted,
    Label,
    NameInput,
    EmailInput,
    PhoneInput,
    MessageInput,
    Button,
    Error,
  },
  setup() {
    const { $axios } = useContext()
    const name = ref('')
    const email = ref('')
    const phone = ref('')
    const message = ref('')
    const isSubmited = ref(false)

    const required = (value: string | null | undefined): Boolean => !!value
    const phoneNumberFormat = (value: string): Boolean =>
      value ? !!value.match(/\d{2,3}-\d{1,4}-\d{4}$/) : true
    const params = useValidation({
      name: {
        $value: name,
        required,
        $message: 'お名前を入力してください',
      },
      email: {
        $value: email,
        required,
        $message: 'メールアドレスを入力してください',
      },
      phone: {
        $value: phone,
        format: {
          $validator: phoneNumberFormat,
          $message: '電話番号の形式が不正です',
        },
      },
      message: {
        $value: message,
        required,
        $message: 'お問い合わせ内容を入力してください',
      },
    })

    const onSubmit = async (): Promise<any> => {
      if (!params.$anyInvalid) {
        return await $axios
          .post('/inquiry', {
            inquiry: {
              name,
              email,
              phone,
              message,
            },
          })
          .then(() => {
            isSubmited.value = true
          })
          .catch(() => {})
      } else {
        return await params.$touch()
      }
    }

    return {
      name,
      email,
      phone,
      message,
      isSubmited,
      params,
      onSubmit,
    }
  },
})
</script>

画面の表示

バリデーションが発生した際の画面が以下のようになります。
(改善点はいくつかありますが)ひとまずこれで完成です🎉

9. まとめ

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

雑ではありましたが、Nuxt.jsでComposition APIを使ってお問い合わせフォームを実装することが出来ました。
触っている中で「ここもっとこう書いた方がいいぞ!」というリファクタリングポイントがいくつもあったことと思います( ex. setup関数内の処理を外部に切り出した方が良さそう...etc )。

ぜひ余力がある方はリファクタリングしてみたり、ご自身のアレンジで遊んでみてください。
最後までご覧くださりありがとうございました👋

参考リンク