
Vue.js連載の1本目です。
はじめに
私は普段Reactを触ることが多く、フォーム開発ではreact-hook-form
+ zod
(+ shadcn/ui
)の組み合わせでの開発をよく行なっていました。
今回Vueでプロジェクトを進めるにあたり、vee-validateを使ってフォーム開発を行ないました。コンポーネントやZodとの組み合わせに試行錯誤したことに加え、アクセシビリティ対応にも力を入れたので、そこで得た知見を共有いたします。
ここで紹介する内容を理解すれば、ほとんどのフォーム実装に対応できるはずです。 より複雑なケースでも、この記事の内容を組み合わせたり派生させることで実現できると思います。ぜひ参考にしてみてください!
また、以下にサンプルとなるリポジトリを用意しましたので、こちらもご参照ください。
https://github.com/hasehiro0828/sample-vue-form
技術スタック概要
vee-validate
https://vee-validate.logaretm.com/v4/
Vue.js向けのフォームライブラリです。Zodなどとの統合が容易で、Composition APIとの親和性も高いです。
Reactの場合はフォームライブラリにいくつか選択肢がある印象ですが、Vueの場合は基本的にvee-validateを使うことになりそうです(2025年10月時点)。
Zod
TypeScript-firstなスキーマ定義ライブラリです。型安全性を担保しながらランタイムバリデーションを実現でき、フレームワークによらず同じように利用できます。
vue-query
https://tanstack.com/query/latest/docs/framework/vue/overview
サーバーステートの管理を行なうライブラリで、Tanstack QueryのVue版です。実践的なアプリケーションを見据えて導入しましたが、今回のメイン要素ではないので詳細は割愛します。
shadcn-vue
Tailwind CSSベースのUIコンポーネント集で、Reactのshadcn/uiのVue版です。こちらも今回のメイン要素ではないので詳細は割愛します。
取得したデータを watch
で resetForm
することで、フォームの初期値を設定します。
アクセシビリティ対応
今回のプロジェクトではアクセシビリティ対応にも力を入れました。デジタル庁のデザインシステムを参考に、日付入力の実装やボタン要素の扱い(disabled
属性を利用しない)など、アクセシビリティの観点から工夫を行なっています。
本記事ではとくに試行錯誤した日付入力の実装について後述します。
vee-validateの基本方針
vee-validateはさまざまなコンポーネントやAPIを提供してくれています。それらを利用するにあたり、利用するもの・利用しないものの基本方針を明確にしておくことで、チーム開発での一貫性を保ち、実装の迷いを減らすことができます。
今回は以下のような基本方針を設けて実装を行ないました。
利用するもの
- Fieldコンポーネント
- 単一のフォームフィールドを扱う際の基本となるコンポーネント
v-slot
でfield、meta、errorMessageを受け取り、入力欄とバリデーション結果を紐づけ<script>
内でフィールドの値を直接操作する必要がない場合に使用
- ErrorMessageコンポーネント
- フォーム全体やグループ全体に対するエラーメッセージの表示に利用
- 相関チェックの結果を親要素に設定するようなバリデーションエラーの表示などに適している(例: 日付の存在チェックなど)
- useFieldArray
- 動的に追加・削除可能な配列形式のフィールドを管理するComposable関数
fields
、push
、remove
などの関数を提供し、配列形式のフィールドの操作を行なえる- FieldArrayコンポーネントではなくこちらを使用する理由は、
push
やremove
などの操作を<script>
内で柔軟に扱えるため
- useForm
- フォーム全体の状態管理を行なうComposable関数
- バリデーションスキーマの設定、送信処理、フォームの値やエラー状態の取得などを行なう
- useField
- 複数の入力欄を持つフォーム機能を統合したカスタムコンポーネントを作成する際に使用
- 基本的にはFieldコンポーネントを使用する方がシンプルだが、複数のinputを持つカスタムコンポーネントを作成するような場合は
useField
でそれぞれのフィールドを個別に定義する方が見通しが良くなる
利用しないもの
- FieldArrayコンポーネント
- 配列の操作(追加・削除)を
<script>
内で行なうケースが多く、useFieldArray
の方が柔軟性が高い
- 配列の操作(追加・削除)を
- defineField from useForm
meta
(touched、dirtyなど)が使えない?みたいなので利用用途がわからず・・useField
を使って正しく動作したので利用しなくて良さそう・・?
具体的な実装例
以降では、上記の基本方針に基づいた具体的な実装例を紹介していきます。
Fieldを使った基本的な入力
<Field v-slot="{ field, meta, errorMessage }" name="name"> |
シンプルな単一フィールドです。
テキスト入力やテキストエリア、セレクトボックスなど、1つの入力欄で完結するフィールドに使用します。
ポイント:
field.value
で現在の値を取得field.onChange
で値の変更を検知field.onBlur
でフォーカスが外れたことを検知shouldShowError
でバリデーションエラーを表示するかを判定errorMessage
でバリデーションエラーメッセージを取得
これらは以降のフォーム実装でも同様です。
useFieldArrayを使った配列管理
<script setup> |
動的に追加・削除可能なフィールドです。
ユーザー操作で追加・削除できるような入力に使用します。
ポイント:
push
で配列に新しい要素を追加remove
で指定したインデックスの要素を削除- v-forのkeyには
{array}.key
(インデックスではなくkey)を使用
ネストした配列要素はコンポーネント分離する
配列の中に配列があるような場合には、ネストされる配列を別コンポーネントに分離すると扱いやすくなります。
サンプルでは以下のような構造にしています。
conditions(配列)← NestForm.vue で管理 |
親コンポーネント(NestForm.vue)
<script setup> |
子コンポーネント(ConditionalParams.vue)
<script setup> |
この設計のメリット:
useFieldArray
を使用する際にパス名が必要だが、コンポーネント分離することでindexを渡すことが可能に- ネストが深くなっても各コンポーネントは自分が管理する要素だけを見ればいいので、コードが読みやすくなる
この構成は3階層以上の深いネストにも応用できます。各階層をコンポーネントに分離することで個々のコンポーネントの複雑性を増すことなく、複雑なフォームでも保守性の高いコードを維持できます。
ちなみに・・・
nameの値は hoge[index]
の形式と hoge.index
の形式のどちらでも可能なようです。ただ、errors
から値を取得する際には hoge[index]
の形式で取得する必要があるため、基本的には hoge[index]
の形式で指定するのが良さそうです。
useFieldを使ったカスタムコンポーネント
<script setup> |
複数の入力欄で1つのコンポーネントとするような場合に使用します。
とくに、日付入力(年・月・日)のように、個別のフィールドでもバリデーションが必要かつ全体としてもバリデーションが必要な場合に適しています。
ポイント:
- 各フィールドを個別に
useField
で定義することで、年・月・日それぞれのバリデーションが可能 useFormContext
でフォーム全体のエラー情報を取得し、親要素のエラー(例:「年月日をすべて入力してください」)を取得- (
useField
でルートの要素を定義すると上手く動作しなかったのでこの形に)
- (
- カスタムコンポーネント内で複数フィールドを統合し、外部からは1つのコンポーネントとして扱えるように
エラー表示のタイミング
export const submittedOrTouchedAndDirty = (submitCount: number, meta: { touched: boolean; dirty: boolean }) => { |
エラー表示の条件は meta.valid
以外にも以下のような条件を設けています。
touched && dirty
: フィールドにフォーカスを当て(touched)、かつ値を変更した(dirty)後にエラーを表示submitCount > 0
: 送信ボタンを押した後は、すべてのフィールドのエラーを表示- エラーがなくなるまでボタンを
disabled
にする実装も考えられるが、アクセシビリティを考慮しdisabled
を使用しない形にしている - その場合
touched && dirty
のみだとsubmitしてもエラーが表示されないため、この条件を追加
- エラーがなくなるまでボタンを
以上のようにすることで、ユーザーが何も操作していない状態でエラーが表示されることや入力中にエラーが表示されてしまうことを避けられます。また、送信ボタンを押した際にはすべてのフィールドのエラーが表示されます。
Zodスキーマ設計
typeフィールドによる分岐
今回、配列形式かつ種類の違うフィールドを動的に管理する必要がありました。これを解決するために、typeフィールドによるスキーマの分岐を行なっています。こうすることで、画面表示の分岐を自動的に行ないつつ型安全にデータを扱えるようになります。
以下のようにbase
のスキーマを拡張しtype
で分岐させるように実装しています。
const baseParamSchema = z.object({ |
コンポーネント側では以下のように利用します。
<template v-if="param.value.type === 'text'"> |
readonlyフィールド(フォームで入力しない値)の活用
フォームでは入力しない値もスキーマに含めて定義することにしています。
readonly: z.object({ |
この構成にしておくと、フォームデータと表示用データを1つのオブジェクトで管理できます。とくに配列操作において恩恵が大きく、追加・削除する際に表示情報も一緒に管理でき、画面表示やAPI送信時のデータの扱いが楽になります。
一緒に管理しない場合、フォームの入力とAPIから取得したデータを突き合わせる必要が出てきてしまうのでこのようにしました。
// 配列に要素を追加する際、表示情報も一緒に管理 |
フォームの入力値との区別がつきやすいように readonly
の下に定義することとしましたが、これは議論の余地があるかもしれません。
グループ入力のバリデーション
refine
を使って複数のinputを組み合わせたチェックを行なっています。個々のinputではなくルートにエラーメッセージを登録し、画面側の表示ロジックを工夫することでエラーの優先順位を調整しています。
const dateParamSchema = baseParamSchema |
コンポーネントのエラー表示は以下のようにしています。
ルートのエラーを優先し、ルートのエラーがない場合は個別のフィールドのエラーを表示するようにしました。
<div class="text-sm text-red-600"> |
日付入力の実装について
デジタル庁のデザインシステムでは、年月日を個別の入力フィールドに分けることが推奨されており、今回はそれに準拠した形で実装を行ないました。
しかし、この方式を採用すると、以下の点で実装が複雑になります。
- 3つのフィールドを1つのバリデーション対象として扱う必要もあり、個別のエラーとグループ全体のエラーを使い分ける必要がある
- 3つのフィールド全体からフォーカスが外れたことを検知するフォーカス管理も必要
この複雑さに対処するため、上で実装したuseField
を使ったカスタムコンポーネントを実装しています。
この章では、上では触れなかった部分についてもう少し補足します。
type="number"
ではなく type="text"
を利用する
年月日の入力にはtype="text"
を採用しています。
<input |
理由は以下の通りです。
- ゼロ埋め対応
type="number"
では先頭のゼロが自動的に削除されてしまう- しかし日付では「03月」「09日」のようにゼロ埋めした入力を許容したい
- そもそもの用途の違い
type="number"
は本来、数量や個数など計算対象となる数値のためのもの- HTML Standardでも「数字のみで構成されているが、厳密には数値ではない入力には
type="number"
の使用は適さない」といったことが記載されている The type=number state is not appropriate for input that happens to only consist of numbers but isn’t strictly speaking a number
日付入力のZodスキーマ定義
日付入力の具体的なZodスキーマ実装の一部を紹介します。
年月日の各要素は以下のように定義することで、textとして入力しつつ数値としての妥当性をチェックしています。
export const zodYear = z |
上記は全画面で共通の基本的なスキーマを定義しましたが、各画面のスキーマでは、範囲チェックなど画面固有のスキーマを定義しています。
const dateRangeParamSchema = baseParamSchema |
他にも、「必須チェック」や「年月日すべて入力したかのチェック」なども実装しているので、より詳しくはサンプルリポジトリのコードを参照してください。
- https://github.com/hasehiro0828/sample-vue-form/blob/main/sample-vue-form-frontend/src/lib/schemas/date-schemas.ts
- https://github.com/hasehiro0828/sample-vue-form/blob/main/sample-vue-form-frontend/src/pages/NestForm/form-schema.ts
まとめ
vee-validateとZodを使ったVue.jsでのフォーム開発について紹介しました。
@tanstack/vue-queryとの組み合わせやユーザビリティを考慮したエラー表示、アクセシビリティを踏まえた実装など、かなり実践的な内容も盛り込みました。
この記事で紹介した内容をおさえていれば、基本的にはどのようなフォーム開発にも対応できるはずです。
ぜひ、サンプルリポジトリも参照して、実際の開発にご活用ください!
https://github.com/hasehiro0828/sample-vue-form
最後までお読みいただきありがとうございました。