らぼるてっく。

てっくてっく歩いてっく。

Vue3を用いた「リアルタイムバリデーション」の実装方法

はじめに

こんにちは🧑‍💻 ラボルのUIデザイナー/フロントエンドエンジニアの寺岡です。

早速ですがフロントエンドのみなさん、フォームのリアルタイムバリデーションってどのように実装されてますでしょうか?

リアルタイムバリデーションとは、入力時やフォーカスアウト時に入力内容のチェックを行い、OKかNGかの結果をリアルタイムに表示する以下のようなフォームのことですね。

出典: Form Validation GIF

「あるフォームの400エラーが著しく多い」などの問題がある場合、UX改善として実装する機会があるかと思います。 というわけで、今回はリアルタイムバリデーションの実装方法についての記事になります!

目次

準備

仕様

今回、仕様は以下のようにしました。

  • 概要: 入力するたびに入力値のバリデーションチェックを行い、もし入力値がNGパターンであればエラーを表示する。
  • バリデーション:
    • hogeフォーム: 入力値が未入力ではないこと
    • fugaフォーム: 5文字以上入力されていること

完成イメージ

上記の仕様で実装したものを先にお見せします👀 バリデーションルールがNGだと、エラーメッセージが表示されます。

実装手順

今回はVueで実装しました。 コードの全てが見たい方は、codesandboxで確認してください。

では順番に説明していきます。

1. formの入力値を用意

いつも通り、リアクティブな値を用意します。 後ほど、inputイベント時に実行する処理を書きたいので、v-modelではなく:value+@inputの書き方をしています。

// App.vue

<template>
  <div>
    <label for="hoge"></label>
    <input
      id="hoge"
      type="text"
      :value="formState.hoge"
      @input="onInputForm('hoge', $event.target.value)"
    />
  </div>
</template>

<script setup>
import { reactive } from "vue";

// 入力値の用意
const formState = reactive({
  hoge: "",
});

// input時に入力値を更新
const onInputForm = (id, value) => {
  formState[id] = value;
};
</script>

2. errorMessageをformごとに用意

hogeに表示する用のエラーメッセージをArrayで用意します。 template内にv-forでリストレンダリングしていますが、今はまだ何も表示されなくてOKです。

// App.vue

<template>
  <div>
    <!-- 省略 -->

    <ul>
      <li v-for="message in errorMessagesState.hoge" :key="message">
        {{ message }}
      </li>
    </ul>
  </div>
</template>

<script setup>
// エラーメッセージをformごとに用意
const errorMessagesState = reactive({
  hoge: [],
});
</script>

3. 空文字orエラー文のStringを返すFunctionを定義

正しくは関数を返す関数という説明になりますが、中身は入力値を受け取ったらStringを返すバリデーションルール用の関数です。Result型のようなものを返してもいいかもしれません。 あとでVue Component側で呼び出すのでexportしておきましょう。

// validatorOptions.js

export const notBlank = () => {
  // 入力値がemptyかスペースのみの場合、エラー文を返す
  return (v) => {
    if (!v || !v.match(/\S/g)) {
      return "入力してください。";
    }

    return "";
  };
};

// あとで他のバリデーションも追加する想定

4. 各formのバリデーションルールを追加

3で定義したバリデーションルールFunctionをvalidatorsStateという変数内でArrayの中に追加していきます。 hogeのバリデーションルールはnotBlankのみなので、以下のようになります。

// App.vue

<template>
  <!-- 省略 -->
</template>

<script setup>
import { notBlank } from "./validatorOptions";

const validatorsState = {
  hoge: [notBlank()],
});
</script>

5. errorMessageの更新

2で用意していたerrorMessagesStateを、inputイベント発火時に更新します。 validatorsState.hoge.mapでバリデーションチェックを実行し、.filterでエラーメッセージのStringのみをerrorMessagesStateに格納します。

これで、フォームの入力を空にするとエラーメッセージが表示されるはずです!

// App.vue

<template>
  <!-- 省略 -->
</template>

<script setup>

const onInputForm = (id, value) => {
  // valueの更新
  formState[id] = value;
  // errorMessageの更新
  errorMessagesState[id] = validatorsState[id]
    .map((validate) => {
      return validate(value);
    })
    .filter((msg) => msg !== "");
};

</script>

6. エラー時の表示を作る

errorMessagesState[id].lengthで、エラーが一つでも出ているかの判定を行い、classをbindさせます。 あとは.input-errorの時に背景を赤色にするなど、わかりやすいエラー用の表示をCSSで作るだけです!

<template>
  <input
    :class="[
      'input',
      { 'input-error': errorMessagesState.hoge.length },
    ]"
    type="text"
    id="hoge"
    :value="formState.hoge"
    @input="onInputForm('hoge', $event.target.value)"
  />
</template>

<script setup>
  // 省略
</script>

完成!

fugaフォームも同様に実装した結果、コード全体は以下になります。 ちゃんと以下のバリデーションチェックが行われていますね。

  • hogeフォーム: 入力値が未入力ではないこと
  • fugaフォーム: 5文字以上入力されていること
// App.vue

<template>
  <div class="formGroup">
    <label class="formGroup__label" for="hoge">HOGE</label>
    <input
      :class="[
        'formGroup__input',
        { 'formGroup__input-error': errorMessagesState.hoge.length },
      ]"
      type="text"
      id="hoge"
      placeholder="なんでもいいので入力"
      :value="formState.hoge"
      @input="onInputForm('hoge', $event.target.value)"
    />
    <ul class="formGroup__errorMessages">
      <li v-for="message in errorMessagesState.hoge" :key="message">
        {{ message }}
      </li>
    </ul>
  </div>

  <div class="formGroup">
    <label class="formGroup__label" for="fuga">FUGA</label>
    <input
      :class="[
        'formGroup__input',
        { 'formGroup__input-error': errorMessagesState.fuga.length },
      ]"
      type="text"
      id="fuga"
      placeholder="5文字以上で入力"
      :value="formState.fuga"
      @input="onInputForm('fuga', $event.target.value)"
    />
    <ul class="formGroup__errorMessages">
      <li v-for="message in errorMessagesState.fuga" :key="message">
        {{ message }}
      </li>
    </ul>
  </div>
</template>

<script setup>
import { reactive } from "vue";
import { notBlank, atLeast } from "./validatorOptions";

const formState = reactive({
  hoge: "",
  fuga: "",
});
const errorMessagesState = reactive({
  hoge: [],
  fuga: [],
});
const validatorsState = ({
  hoge: [notBlank()],
  fuga: [atLeast(5)],
});

const onInputForm = (id, value) => {
  // valueの更新
  formState[id] = value;
  // errorMessageの更新
  errorMessagesState[id] = validatorsState[id]
    .map((validate) => {
      return validate(value);
    })
    .filter((msg) => msg !== "");
};
</script>
// validatorOptions.js

export const notBlank = () => {
  return (v) => {
    if (!v || !v.match(/\S/g)) {
      return "入力してください。";
    }

    return "";
  };
};

export const atLeast = (digit) => {
  return (v) => {
    if (v.length < digit) {
      return `${digit}文字以上で入力してください。`;
    }

    return "";
  };
};

🧑‍💻コード全体を見る

実装ポイントまとめ

今回のようなリアルタイムバリデーションの実装ポイントをまとめます。

1. 高階関数をバリデーションルール定義用のArrayに格納

const validatorsState = ({
  id: [notBlank(), atLeast(5)],
});

上記のvalidatorsStateに、フォームごとのバリデーションルールをArrayに格納していましたね。 console.log(validatorsState.id)するとわかりやすいのですが、(2) [ƒ (), ƒ ()]のように「Functions in Array」の構造になっています。

以下のように、notBlankは「vを受け取って何らかの処理を行う無名関数」を返していますよね。

export const notBlank = () => {
  return (v) => {
    if (!v || !v.match(/\S/g)) {
      return "入力してください。";
    }

    return "";
  };
};

2. inputイベントで入力のたびにバリデーションチェックを実行

// inputイベント発火のたびにこの処理が動く
const onInputForm = (id, value) => {
  errorMessagesState[id] = validatorsState[id]
    .map((validate) => {
      return validate(value);
    })
    .filter((msg) => msg !== "");
};

@input="onInputForm(id, value)"で、入力するたびに上記のバリデーションチェックの処理が動き、最終的にエラーメッセージをArrayに格納するようにしています。 今回の実装ではバリデーションチェックのタイミングは「inputした時」でしたが、もし「blurした時」に変えたければ、@blurで発火させるように変更するだけですね。

なぜこの実装方法か

今回はnotBlank()atLeast(digit)など、バリデーションチェック用のロジックをvalidatorOptions.jsにまとめて置いておく設計でした。 このやり方をとった理由をまとめます。

1. バリデーションロジックの再利用性が高い

本来、フォームは複数箇所で使う場合がほとんどだと思います。 会員登録フォーム・ログインフォーム・お問い合わせフォームなど、色々なフォームにリアルタイムバリデーションを実装したいですよね。

共通のjsファイルにバリデーションロジックをまとめておけば、同じロジックを複数コンポーネントで使えるので、再利用性が高いと言えます。

2. テストがしやすい

今回のバリデーションロジックはjsファイルに切り分けされているので、自動テストで完結します。 つまり、いちいちフォームの入力を試す手動テストの手間が省けます。

以下のように、期待通りのエラーメッセージが返ってくるかのテストを書けば完璧ですね。

// validatorOptions.test.js

import { notBlank } from "./validatorOptions";

// エラーメッセージ取得
const getErrorMessages = (validators, value) => {
  return validators
    .map((validate) => {
      return validate(value);
    })
    .filter((msg) => msg !== "");
}

// notBlank()のテスト
test('未入力どうかの判定', () => {
  const validators = [ notBlank() ];

  expect(getErrorMessages(validators, "")).toEqual([ "入力してください。" ]);
  expect(getErrorMessages(validators, "テスト")).toEqual([]);
});

まとめ

リアルタイムバリデーションの実装方法は色々な方法があると思いますが、以下のメリットを感じたい方は、今回紹介した方法がオススメです🍻

  1. バリデーションロジックの再利用性が高い
  2. テストがしやすい

また、他にみなさんのオススメの実装方法があれば是非教えてください!!

それでは!👋

最後に

ラボルでは、エンジニアを積極採用中です。1、2年目のエンジニアから経験豊富なテックリードやエンジニリングマネージャーまで、興味がある方はぜひご応募ください!!

labol.co.jp