フューチャー技術ブログ

Vue.js + vee-validate + Zod(+ shadcn/vue + @tanstack/vue-query)での実践的なフォーム開発

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

https://zod.dev/

TypeScript-firstなスキーマ定義ライブラリです。型安全性を担保しながらランタイムバリデーションを実現でき、フレームワークによらず同じように利用できます。

vue-query

https://tanstack.com/query/latest/docs/framework/vue/overview

サーバーステートの管理を行なうライブラリで、Tanstack QueryのVue版です。実践的なアプリケーションを見据えて導入しましたが、今回のメイン要素ではないので詳細は割愛します。

shadcn-vue

https://www.shadcn-vue.com/

Tailwind CSSベースのUIコンポーネント集で、Reactのshadcn/uiのVue版です。こちらも今回のメイン要素ではないので詳細は割愛します。

取得したデータを watchresetForm することで、フォームの初期値を設定します。

アクセシビリティ対応

今回のプロジェクトではアクセシビリティ対応にも力を入れました。デジタル庁のデザインシステムを参考に、日付入力の実装やボタン要素の扱い(disabled属性を利用しない)など、アクセシビリティの観点から工夫を行なっています。

本記事ではとくに試行錯誤した日付入力の実装について後述します。

vee-validateの基本方針

vee-validateはさまざまなコンポーネントやAPIを提供してくれています。それらを利用するにあたり、利用するもの・利用しないものの基本方針を明確にしておくことで、チーム開発での一貫性を保ち、実装の迷いを減らすことができます。
今回は以下のような基本方針を設けて実装を行ないました。

利用するもの

  • Fieldコンポーネント
    • 単一のフォームフィールドを扱う際の基本となるコンポーネント
    • v-slotでfield、meta、errorMessageを受け取り、入力欄とバリデーション結果を紐づけ
    • <script>内でフィールドの値を直接操作する必要がない場合に使用
  • ErrorMessageコンポーネント
    • フォーム全体やグループ全体に対するエラーメッセージの表示に利用
    • 相関チェックの結果を親要素に設定するようなバリデーションエラーの表示などに適している(例: 日付の存在チェックなど)
  • useFieldArray
    • 動的に追加・削除可能な配列形式のフィールドを管理するComposable関数
    • fieldspushremoveなどの関数を提供し、配列形式のフィールドの操作を行なえる
    • FieldArrayコンポーネントではなくこちらを使用する理由は、pushremoveなどの操作を<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">
<div class="flex flex-col gap-2">
<label :for="nameId">
名前
<span class="text-red-600 bg-red-100 px-1 py-0.5 rounded-md text-xs ml-1">必須</span>
</label>
<Input
:id="nameId"
:model-value="field.value"
type="text"
:aria-invalid="shouldShowError(submitCount, meta)"
@update:model-value="field.onChange"
@blur="field.onBlur"
/>
<p v-if="shouldShowError(submitCount, meta)" class="text-sm text-red-600">
{{ errorMessage }}
</p>
</div>
</Field>

シンプルな単一フィールドです。
テキスト入力やテキストエリア、セレクトボックスなど、1つの入力欄で完結するフィールドに使用します。

ポイント:

  • field.valueで現在の値を取得
  • field.onChangeで値の変更を検知
  • field.onBlurでフォーカスが外れたことを検知
  • shouldShowErrorでバリデーションエラーを表示するかを判定
  • errorMessageでバリデーションエラーメッセージを取得

これらは以降のフォーム実装でも同様です。

useFieldArrayを使った配列管理

<script setup>
const { fields, remove, push } = useFieldArray("conditions");

const addCondition = () => {
push({
// 初期値
});
};
</script>

<template>
<div v-for="(condition, idx) in fields" :key="condition.key">
<!-- ネストした配列は別コンポーネントで管理 -->
<ConditionalParams :index="idx" />
<Button @click="() => remove(idx)">削除</Button>
</div>
</template>

動的に追加・削除可能なフィールドです。
ユーザー操作で追加・削除できるような入力に使用します。

ポイント:

  • pushで配列に新しい要素を追加
  • removeで指定したインデックスの要素を削除
  • v-forのkeyには{array}.key(インデックスではなくkey)を使用

ネストした配列要素はコンポーネント分離する

配列の中に配列があるような場合には、ネストされる配列を別コンポーネントに分離すると扱いやすくなります。
サンプルでは以下のような構造にしています。

conditions(配列)← NestForm.vue で管理
└── params(配列)← ConditionalParams.vue で管理
親コンポーネント(NestForm.vue)
<script setup>
// 外側の配列を管理
const { fields: conditionFields, remove, push } = useFieldArray("conditions");
</script>

<template>
<div v-for="(condition, conditionIdx) in conditionFields" :key="condition.key">
<div class="mb-2">
<div class="font-bold">{{ condition.value.readonly.title }}</div>
<div class="text-gray-500 text-sm">{{ condition.value.readonly.description }}</div>
</div>
<!-- ネストした配列の管理は子コンポーネントで行う -->
<ConditionalParams :index="conditionIdx" />
<Button @click="() => remove(conditionIdx)">条件を削除</Button>
</div>
</template>
子コンポーネント(ConditionalParams.vue)
<script setup>
const props = defineProps<{ index: number }>();

// 親から受け取ったindexを使ってパスを構築
const paramKey = computed(() => `conditions[${String(props.index)}].params`);

const { fields: params, remove, push } = useFieldArray<Param>(paramKey.value);

const handleAddParam = () => {
push({
type: "text",
value: { text: "" },
required: true,
readonly: { title: "", description: "" }
});
};
</script>

<template>
<div v-for="(param, paramIdx) in params" :key="param.key">
<!-- paramの内容を表示/編集 -->
<template v-if="param.value.type === 'text'">
<Field :name="`${paramKey}[${paramIdx}].value.text`" v-slot="{ field }">
<Input v-model="field.value" />
</Field>
</template>
<Button @click="() => remove(paramIdx)">パラメータ削除</Button>
</div>
<Button @click="handleAddParam">パラメータを追加</Button>
</template>

この設計のメリット:

  • useFieldArrayを使用する際にパス名が必要だが、コンポーネント分離することでindexを渡すことが可能に
  • ネストが深くなっても各コンポーネントは自分が管理する要素だけを見ればいいので、コードが読みやすくなる

この構成は3階層以上の深いネストにも応用できます。各階層をコンポーネントに分離することで個々のコンポーネントの複雑性を増すことなく、複雑なフォームでも保守性の高いコードを維持できます。

ちなみに・・・
nameの値は hoge[index] の形式と hoge.index の形式のどちらでも可能なようです。ただ、errorsから値を取得する際には hoge[index]の形式で取得する必要があるため、基本的には hoge[index] の形式で指定するのが良さそうです。

useFieldを使ったカスタムコンポーネント

<script setup>
const props = defineProps<{
namePrefix: string;
type: "date" | "month" | "year";
}>();

// 個別のフィールドを定義
const yearField = useField<string>(`${props.namePrefix}.year`);
const monthField = useField<string>(`${props.namePrefix}.month`);
const dayField = useField<string>(`${props.namePrefix}.day`);

// フォーム全体のエラーを取得
const { errors } = useFormContext();

const rootErrorMessage = computed(() => errors.value[props.namePrefix]);
</script>

<template>
<AppDateInput
:year="yearField.value.value"
:month="monthField.value.value"
:day="dayField.value.value"
@blur:year="yearField.handleBlur"
@blur:month="monthField.handleBlur"
@blur:day="dayField.handleBlur"
/>
</template>

複数の入力欄で1つのコンポーネントとするような場合に使用します。
とくに、日付入力(年・月・日)のように、個別のフィールドでもバリデーションが必要かつ全体としてもバリデーションが必要な場合に適しています。

ポイント:

  • 各フィールドを個別にuseFieldで定義することで、年・月・日それぞれのバリデーションが可能
  • useFormContextでフォーム全体のエラー情報を取得し、親要素のエラー(例:「年月日をすべて入力してください」)を取得
    • useFieldでルートの要素を定義すると上手く動作しなかったのでこの形に)
  • カスタムコンポーネント内で複数フィールドを統合し、外部からは1つのコンポーネントとして扱えるように

エラー表示のタイミング

export const submittedOrTouchedAndDirty = (submitCount: number, meta: { touched: boolean; dirty: boolean }) => {
return submitCount > 0 || (meta.touched && meta.dirty);
};

export const shouldShowError = (submitCount: number, meta: { touched: boolean; dirty: boolean; valid: boolean }) => {
return submittedOrTouchedAndDirty(submitCount, meta) && !meta.valid;
};

エラー表示の条件は meta.valid 以外にも以下のような条件を設けています。

  • touched && dirty: フィールドにフォーカスを当て(touched)、かつ値を変更した(dirty)後にエラーを表示
  • submitCount > 0: 送信ボタンを押した後は、すべてのフィールドのエラーを表示
    • エラーがなくなるまでボタンをdisabledにする実装も考えられるが、アクセシビリティを考慮しdisabledを使用しない形にしている
    • その場合touched && dirty のみだとsubmitしてもエラーが表示されないため、この条件を追加

以上のようにすることで、ユーザーが何も操作していない状態でエラーが表示されることや入力中にエラーが表示されてしまうことを避けられます。また、送信ボタンを押した際にはすべてのフィールドのエラーが表示されます。

Zodスキーマ設計

typeフィールドによる分岐

今回、配列形式かつ種類の違うフィールドを動的に管理する必要がありました。これを解決するために、typeフィールドによるスキーマの分岐を行なっています。こうすることで、画面表示の分岐を自動的に行ないつつ型安全にデータを扱えるようになります。

以下のようにbaseのスキーマを拡張しtypeで分岐させるように実装しています。

const baseParamSchema = z.object({
required: z.boolean(),
type: z.unknown(), // 各サブスキーマで具体的な型に
value: z.unknown(), // 各サブスキーマで具体的な型に
readonly: z.object({
// フォームで入力しない値
title: z.string(),
description: z.string(),
}),
});

const textParamSchema = baseParamSchema.extend({
type: z.literal("text"), // 👈 typeで分岐
value: z.object({
text: z.string(),
}),
});

const dateParamSchema = baseParamSchema.extend({
type: z.literal("date"), // 👈 typeで分岐
value: zodDateValue,
});

// Union型で統合
export const paramSchema = z.union([
textParamSchema,
dateParamSchema,
monthParamSchema,
yearParamSchema,
dateRangeParamSchema,
monthRangeParamSchema,
yearRangeParamSchema,
]);

コンポーネント側では以下のように利用します。

<template v-if="param.value.type === 'text'">
<!-- param.value.value.text が型安全にアクセス可能 -->
<Input v-model="param.value.value.text" />
</template>

<template v-else-if="param.value.type === 'date'">
<DateInputField :name-prefix="`${paramKey}[${paramIdx}].value`" />
</template>

readonlyフィールド(フォームで入力しない値)の活用

フォームでは入力しない値もスキーマに含めて定義することにしています。

readonly: z.object({
title: z.string(), // フィールドのラベル
description: z.string(), // フィールドの説明
}),

この構成にしておくと、フォームデータと表示用データを1つのオブジェクトで管理できます。とくに配列操作において恩恵が大きく、追加・削除する際に表示情報も一緒に管理でき、画面表示やAPI送信時のデータの扱いが楽になります。
一緒に管理しない場合、フォームの入力とAPIから取得したデータを突き合わせる必要が出てきてしまうのでこのようにしました。

// 配列に要素を追加する際、表示情報も一緒に管理
const handleAddParam = () => {
push({
type: "text",
value: { text: "" },
required: true,
readonly: {
title: "新しいフィールド",
description: "説明文",
},
});
};

フォームの入力値との区別がつきやすいように readonly の下に定義することとしましたが、これは議論の余地があるかもしれません。

グループ入力のバリデーション

refineを使って複数のinputを組み合わせたチェックを行なっています。個々のinputではなくルートにエラーメッセージを登録し、画面側の表示ロジックを工夫することでエラーの優先順位を調整しています。

const dateParamSchema = baseParamSchema
.extend({
type: z.literal("date"),
value: zodDateValue,
})
.refine(
(data) => {
if (!data.required) return true;
return data.value.year && data.value.month && data.value.day;
},
{
message: "日付を入力してください",
path: ["value"],
}
);

コンポーネントのエラー表示は以下のようにしています。
ルートのエラーを優先し、ルートのエラーがない場合は個別のフィールドのエラーを表示するようにしました。

<div class="text-sm text-red-600">
<p v-if="shouldShowRootError">
{{ rootErrorMessage }}
</p>
<template v-else>
<p v-if="shouldShowError(submitCount, yearField.meta)">
{{ yearField.errorMessage.value }}
</p>
<p v-else-if="shouldShowError(submitCount, monthField.meta)">
{{ monthField.errorMessage.value }}
</p>
<p v-else-if="shouldShowError(submitCount, dayField.meta)">
{{ dayField.errorMessage.value }}
</p>
</template>
</div>

日付入力の実装について

デジタル庁のデザインシステムでは、年月日を個別の入力フィールドに分けることが推奨されており、今回はそれに準拠した形で実装を行ないました。
しかし、この方式を採用すると、以下の点で実装が複雑になります。

  • 3つのフィールドを1つのバリデーション対象として扱う必要もあり、個別のエラーとグループ全体のエラーを使い分ける必要がある
  • 3つのフィールド全体からフォーカスが外れたことを検知するフォーカス管理も必要

この複雑さに対処するため、上で実装したuseFieldを使ったカスタムコンポーネントを実装しています。

この章では、上では触れなかった部分についてもう少し補足します。

type="number" ではなく type="text" を利用する

年月日の入力にはtype="text"を採用しています。

<input
v-model="yearModel"
type="text" <!-- type="number" ではない -->
/>

理由は以下の通りです。

  • ゼロ埋め対応
    • 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
.string()
.refine((val) => val === "" || !isNaN(Number(val)), { message: "年は数値で入力してください" })
.refine((val) => val === "" || Number(val) >= DATE_CONFIG.year.min, {
message: `年は${DATE_CONFIG.year.min}以上で入力してください`,
})
.refine((val) => val === "" || Number(val) <= DATE_CONFIG.year.max, {
message: `年は${DATE_CONFIG.year.max}以下で入力してください`,
});

export const zodMonth = z
.string()
.refine((val) => val === "" || !isNaN(Number(val)), { message: "月は数値で入力してください" })
.refine((val) => val === "" || Number(val) >= DATE_CONFIG.month.min, {
message: `月は${DATE_CONFIG.month.min}以上で入力してください`,
})
.refine((val) => val === "" || Number(val) <= DATE_CONFIG.month.max, {
message: `月は${DATE_CONFIG.month.max}以下で入力してください`,
});

export const zodDay = z
.string()
.refine((val) => val === "" || !isNaN(Number(val)), { message: "日は数値で入力してください" })
.refine((val) => val === "" || Number(val) >= DATE_CONFIG.day.min, {
message: `日は${DATE_CONFIG.day.min}以上で入力してください`,
})
.refine((val) => val === "" || Number(val) <= DATE_CONFIG.day.max, {
message: `日は${DATE_CONFIG.day.max}以下で入力してください`,
});

上記は全画面で共通の基本的なスキーマを定義しましたが、各画面のスキーマでは、範囲チェックなど画面固有のスキーマを定義しています。

const dateRangeParamSchema = baseParamSchema
.extend({
type: z.literal("date_range"),
value: zodDateRangeValue,
})
.refine(
(data) => {
// 3段階目: 範囲の制約チェック
if (!data.value.from.year || /* ... */) return true;
return isWithinYears(data.value.from, data.value.to, CONFIG.range.date.maxYears);
},
{ message: `範囲は${CONFIG.range.date.maxYears}年以内で入力してください`, path: ["value"] }
);

他にも、「必須チェック」や「年月日すべて入力したかのチェック」なども実装しているので、より詳しくはサンプルリポジトリのコードを参照してください。

まとめ

vee-validateZodを使ったVue.jsでのフォーム開発について紹介しました。

@tanstack/vue-queryとの組み合わせやユーザビリティを考慮したエラー表示、アクセシビリティを踏まえた実装など、かなり実践的な内容も盛り込みました。

この記事で紹介した内容をおさえていれば、基本的にはどのようなフォーム開発にも対応できるはずです。

ぜひ、サンプルリポジトリも参照して、実際の開発にご活用ください!

https://github.com/hasehiro0828/sample-vue-form

最後までお読みいただきありがとうございました。