こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。
先日、久しぶりにバイクの車検をオンラインで予約しようとしたんです。住所、車体番号、保険情報、希望日時……入力項目が多いのは仕方ないとして、ステップを進めるたびに画面が一瞬固まる。戻ると入力が消える。最終確認で「形式が正しくありません」と赤字が出て、どこが間違いなのかスクロールして探す羽目に。
……正直、途中で予約やめようかと思いました。バイク屋に電話したほうが早い。
これ、ユーザーとして体験すると「二度と使いたくない」と感じるのに、開発側では意外と見落とされがちな問題です。会員登録、申込フォーム、審査申請……3〜5ステップのウィザード形式は業務システムでは避けて通れません。しかしバリデーションの設計を間違えると、入力のたびにカクつき、タイムアウト、そして離脱。フォーム完了率がじわじわ下がっていく原因は、実はバリデーションの「置き場所」にあります。
従来のフォームバリデーション、何が重いのか
多くのプロジェクトで見かけるのが、「すべてのバリデーションをクライアント側で実行する」パターンです。Yupやカスタムバリデータに業務ルールまで全部詰め込み、キーストロークのたびに全フィールドを検証する。
一見「リアルタイムで親切」に見えますが、ルールが増えるとこうなります。
- 入力1文字ごとに数十のバリデーション関数が走り、UIスレッドがブロックされる
- 業務固有のルール(重複チェック、在庫確認など)までクライアントに持たせると、JSバンドルが肥大化する
- サーバー側にも同じルールを書く必要があり、二重管理でルールの不整合が起きる
- ステップ間の遷移でフォーム全体を再検証し、体感速度がさらに悪化する
私たちの開発現場でも、ある会員登録フォームで「入力途中にフリーズする」という問い合わせが相次ぎました。調べてみると、1ステップに40以上のバリデーションルールがクライアント側だけで動いていたんです。
解決の核心:バリデーションを「同期」と「業務ロジック」に分割する
発想はシンプルです。バリデーションを2層に分け、それぞれ適切な場所で実行します。
① クライアント側:軽い型チェック+即時フィードバック
ユーザーの入力体験に直結する検証だけをクライアントに残します。文字数制限、メールアドレスの形式、必須項目の空チェックなど、データベースやAPIに問い合わせなくても判断できるものです。ここでは処理が軽いので、キー入力のたびに実行しても問題ありません。
② サーバー側:業務ロジック+整合性チェック
メールアドレスの重複チェック、招待コードの有効性確認、在庫や日程の空き状況など、DBアクセスが必要な検証はサーバーに任せます。クライアントはステップの送信時にだけサーバーへ問い合わせるため、入力中のカクつきがなくなります。
③ ステップ単位の部分送信で差分だけ検証
フォーム全体を一括送信するのではなく、ステップごとに「そのステップの入力値だけ」をサーバーへ送ります。検証対象が絞られるぶんレスポンスが速く、エラーがあっても該当ステップ内で即座にフィードバックできます。
React実装:Zod × Server Actions(Next.js)
React(Next.js 14以降)では、Zodでスキーマを定義し、Server Actionsでサーバーバリデーションを実行する構成が強力です。
まず、ステップごとにZodスキーマを分割します。
// schemas/registration.ts
import { z } from "zod";
// ステップ1:基本情報(クライアント同期チェック用)
export const step1Schema = z.object({
name: z.string().min(1, "名前は必須です").max(50),
email: z.string().email("メールアドレスの形式が正しくありません"),
password: z
.string()
.min(8, "8文字以上で入力してください")
.regex(/[A-Z]/, "大文字を1文字以上含めてください")
.regex(/[0-9]/, "数字を1文字以上含めてください"),
});
// ステップ2:詳細情報
export const step2Schema = z.object({
company: z.string().min(1, "会社名は必須です"),
phone: z.string().regex(/^0\d{9,10}$/, "電話番号の形式が正しくありません"),
postalCode: z.string().regex(/^\d{3}-?\d{4}$/, "郵便番号の形式が正しくありません"),
});
// ステップ3:確認(業務ロジックはサーバー側で実行)
export const step3Schema = z.object({
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: "利用規約への同意が必要です" }),
}),
});ポイントは、Zodスキーマをステップごとに独立させていることです。全フィールドを1つの巨大なスキーマにまとめると、どのステップでどの検証が走るかが不明瞭になります。
次に、Server Actionsでサーバー側バリデーションを実装します。
// app/actions/validateStep.ts
"use server";
import { step1Schema, step2Schema } from "@/schemas/registration";
import { checkEmailDuplicate } from "@/lib/db";
type ValidationResult = {
success: boolean;
errors?: Record<string, string[]>;
};
export async function validateStep1(
formData: FormData
): Promise<ValidationResult> {
const data = {
name: formData.get("name") as string,
email: formData.get("email") as string,
password: formData.get("password") as string,
};
// Zodによる型チェック
const result = step1Schema.safeParse(data);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// 業務ロジック:メールアドレスの重複チェック
const isDuplicate = await checkEmailDuplicate(data.email);
if (isDuplicate) {
return {
success: false,
errors: { email: ["このメールアドレスはすでに登録されています"] },
};
}
return { success: true };
}Server Actionsの利点は、クライアントから通常の関数呼び出しと同じ感覚で使えること。APIルートを別途用意する必要がなく、型もそのまま共有できます。
フォームコンポーネント側では、クライアントバリデーションとサーバーバリデーションを段階的に実行します。
// components/RegistrationForm.tsx
"use client";
import { useState, useTransition } from "react";
import { step1Schema } from "@/schemas/registration";
import { validateStep1 } from "@/app/actions/validateStep";
export function RegistrationForm() {
const [currentStep, setCurrentStep] = useState(1);
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [isPending, startTransition] = useTransition();
const handleNext = async (formData: FormData) => {
setErrors({});
// 1. クライアント側の軽い型チェック(即座に実行)
if (currentStep === 1) {
const clientResult = step1Schema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password"),
});
if (!clientResult.success) {
setErrors(clientResult.error.flatten().fieldErrors);
return;
}
}
// 2. サーバー側の業務ロジックチェック(非同期)
startTransition(async () => {
const serverResult = await validateStep1(formData);
if (!serverResult.success && serverResult.errors) {
setErrors(serverResult.errors);
return;
}
setCurrentStep((prev) => prev + 1);
});
};
return (
<form action={handleNext}>
{currentStep === 1 && (
<fieldset disabled={isPending}>
<div>
<label htmlFor="name">名前</label>
<input id="name" name="name" type="text" />
{errors.name && (
<p role="alert" className="error">{errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="email">メールアドレス</label>
<input id="email" name="email" type="email" />
{errors.email && (
<p role="alert" className="error">{errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password">パスワード</label>
<input id="password" name="password" type="password" />
{errors.password && (
<p role="alert" className="error">{errors.password[0]}</p>
)}
</div>
<button type="submit">
{isPending ? "確認中..." : "次へ"}
</button>
</fieldset>
)}
</form>
);
}useTransitionを使うことで、サーバーへの問い合わせ中もUIがフリーズしません。ユーザーには「確認中…」と表示しつつ、入力フィールドへのフォーカスやスクロールは維持されます。
Vue実装:VeeValidate × サーバーバリデーションAPI
Vue(Nuxt 3)では、VeeValidateとZodを組み合わせて同じ分割戦略を実現できます。
// composables/useStepValidation.ts
import { toTypedSchema } from "@vee-validate/zod";
import { z } from "zod";
export const step1Schema = toTypedSchema(
z.object({
name: z.string().min(1, "名前は必須です").max(50),
email: z.string().email("メールアドレスの形式が正しくありません"),
password: z
.string()
.min(8, "8文字以上で入力してください")
.regex(/[A-Z]/, "大文字を1文字以上含めてください"),
})
);
export const step2Schema = toTypedSchema(
z.object({
company: z.string().min(1, "会社名は必須です"),
phone: z.string().regex(/^0\d{9,10}$/, "電話番号の形式が正しくありません"),
})
);VeeValidateのtoTypedSchemaを使うことで、ZodスキーマをそのままVeeValidateのバリデーションルールとして利用できます。React版とスキーマ定義を共有できるのもZodの強みです。
<!-- components/RegistrationWizard.vue -->
<script setup lang="ts">
import { useForm } from "vee-validate";
import { ref } from "vue";
import { step1Schema, step2Schema } from "~/composables/useStepValidation";
const currentStep = ref(1);
const serverErrors = ref<Record<string, string>>({});
const isSubmitting = ref(false);
const schemas = [step1Schema, step2Schema];
const { handleSubmit, errors, resetForm } = useForm({
validationSchema: computed(() => schemas[currentStep.value - 1]),
});
async function validateOnServer(values: Record<string, unknown>) {
isSubmitting.value = true;
serverErrors.value = {};
try {
const { data, error } = await useFetch("/api/validate-step", {
method: "POST",
body: { step: currentStep.value, values },
});
if (error.value || !data.value?.success) {
serverErrors.value = data.value?.errors ?? {};
return false;
}
return true;
} finally {
isSubmitting.value = false;
}
}
const onNext = handleSubmit(async (values) => {
// クライアント検証はVeeValidateが自動実行済み
// サーバー検証を追加実行
const isValid = await validateOnServer(values);
if (isValid) {
currentStep.value++;
}
});
</script>
<template>
<form @submit.prevent="onNext">
<fieldset :disabled="isSubmitting">
<template v-if="currentStep === 1">
<div>
<label for="name">名前</label>
<Field id="name" name="name" type="text" />
<p v-if="errors.name || serverErrors.name" role="alert" class="error">
{{ errors.name || serverErrors.name }}
</p>
</div>
<div>
<label for="email">メールアドレス</label>
<Field id="email" name="email" type="email" />
<p v-if="errors.email || serverErrors.email" role="alert" class="error">
{{ errors.email || serverErrors.email }}
</p>
</div>
<div>
<label for="password">パスワード</label>
<Field id="password" name="password" type="password" />
<p v-if="errors.password || serverErrors.password" role="alert" class="error">
{{ errors.password || serverErrors.password }}
</p>
</div>
</template>
<button type="submit">
{{ isSubmitting ? '確認中...' : '次へ' }}
</button>
</fieldset>
</form>
</template>Vue版のポイントは、validationSchemaをcomputedで切り替えている点です。ステップが進むたびに検証対象のスキーマが自動で切り替わり、不要なフィールドの検証が走りません。
エラー表示のタイミング設計
バリデーション分割と同じくらい重要なのが、「いつエラーを見せるか」です。ここを間違えると、技術的には軽くなっても体感は重いまま、ということが起きます。
実務で効果があった設計を整理します。
| タイミング | 適用するバリデーション | 理由 |
|---|---|---|
| 入力中(onChange) | 形式チェックのみ(メール形式、文字数) | 即座にフィードバックが欲しい項目に限定 |
| フォーカスアウト(onBlur) | クライアント側の全ルール | 入力を邪魔しないタイミングで検証 |
| 送信時(onSubmit) | サーバー側の業務ロジック | DB問い合わせが必要なものはまとめて実行 |
よくある失敗は、全ルールをonChangeで走らせるパターンです。ユーザーがまだ入力途中なのに「形式が正しくありません」と表示されて、入力する気を削いでしまいます。
また、エラーのハイライトにアニメーションを使う場合は、prefers-reduced-motionへの配慮も忘れずに。
/* エラーフィールドのハイライト */
.field-error {
border-color: #dc2626;
animation: shake 0.3s ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
.field-error {
animation: none;
outline: 2px solid #dc2626;
}
}ステップ戻りで入力値を消さない設計
ウィザード形式で頻繁に報告されるバグが「前のステップに戻ったら入力が消えていた」です。
React版ではuseRefやContextで各ステップの値を保持し、Vue版ではkeep-aliveでコンポーネントの状態を維持する方法が定番です。
// React:ステップ間の値を保持するContext
const WizardContext = createContext<{
stepData: Record<number, Record<string, unknown>>;
updateStep: (step: number, data: Record<string, unknown>) => void;
}>(null!);
export function WizardProvider({ children }: { children: React.ReactNode }) {
const [stepData, setStepData] = useState<
Record<number, Record<string, unknown>>
>({});
const updateStep = (step: number, data: Record<string, unknown>) => {
setStepData((prev) => ({ ...prev, [step]: data }));
};
return (
<WizardContext.Provider value={{ stepData, updateStep }}>
{children}
</WizardContext.Provider>
);
}ステップを進める前にupdateStepで現在の入力値を保存し、戻ったときに復元する。単純ですが、これだけで離脱率が目に見えて改善します。
実際のプロジェクトで得られた効果
あるBtoB SaaSの審査申込フォーム(5ステップ、合計35フィールド)にこの分割設計を導入したときの結果です。
| 指標 | 導入前 | 導入後 | 改善率 |
|---|---|---|---|
| キー入力〜エラー表示のレイテンシ | 180〜350ms | 8〜25ms | 約93%短縮 |
| フォーム関連のJSバンドルサイズ | 142KB(gzip) | 47KB(gzip) | 約67%削減 |
| フォーム完了率 | 38% | 61% | +23pt |
| 途中離脱が最も多いステップ | ステップ2(入力中フリーズ) | ステップ4(確認画面での離脱) | ボトルネック移動 |
特に注目したいのはバンドルサイズの変化です。業務ロジックのバリデーションコードをサーバーに移したことで、クライアントに配信するJS量が大幅に減りました。モバイル回線でのフォーム表示速度にも好影響が出ています。
途中離脱のボトルネックが「ステップ2のフリーズ」から「ステップ4の確認画面」に移ったのも興味深い結果でした。技術的な問題が解消されたことで、UXやコピーライティングなど本来取り組むべき課題にフォーカスできるようになったわけです。
導入時に気をつけたいポイント
この設計を現場に持ち込む際、いくつかハマりやすいポイントがあります。
サーバーとクライアントのルール不整合
Zodスキーマをサーバー・クライアント間で共有すれば、型レベルでの不整合は防げます。ただし「メールアドレスの重複チェックはサーバーだけ」のように、片方にしか存在しないルールは明示的にドキュメント化しておくべきです。どのルールがどこで実行されるかの一覧表を作っておくと、後任の開発者が迷いません。
不安定な回線でのサーバーバリデーションUX
モバイル環境や社内ネットワークが不安定な場合、サーバーへの問い合わせがタイムアウトすることがあります。タイムアウト時のリトライUIと、オフライン時のフォールバック(「接続を確認してください」メッセージ)は必ず用意してください。
モバイルIMEとの相性
日本語入力のIME変換中にonChangeが発火すると、変換確定前にバリデーションが走ってしまいます。compositionstart/compositionendイベントを監視して、変換中はバリデーションをスキップする処理を入れておくと安全です。
まとめ
フォームの「重さ」は、多くの場合バリデーションの配置ミスから生まれます。すべてをクライアントに詰め込む従来のやり方から、「軽い型チェックはクライアント、業務ロジックはサーバー」という分割設計に切り替えるだけで、入力体験は劇的に改善します。
今回の内容を振り返ると、押さえておきたいのは次の3点です。
- バリデーションは「同期チェック」と「業務ロジック」に分割し、実行場所を明確にする
- ステップ単位の部分送信で検証対象を最小化し、レスポンスを速くする
- エラー表示のタイミング(onChange / onBlur / onSubmit)を意図的に設計する
実際のプロジェクトでは、この設計によりフォーム完了率が23ポイント改善し、JSバンドルサイズも67%削減できました。Zodでスキーマを共有すればReactでもVueでも同じ戦略が使えるので、フレームワークを問わず応用が利きます。
株式会社ファストコーディングでは、こうしたフォーム最適化やフロントエンドのパフォーマンス改善に数多く取り組んでいます。「フォームの離脱率が高い」「入力中にカクつく」といったお悩みがあれば、お問い合わせフォームからお気軽にご相談ください。次のプロジェクトで、ぜひこのバリデーション分割設計を試してみてください。

