React, Vue.js
投稿日:

“入力が重い!”を解消する ─ Zod × Server ActionsとVeeValidateによるバリデーション分割設計

入力が重い!を解消するバリデーション分割設計

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族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版のポイントは、validationSchemacomputedで切り替えている点です。ステップが進むたびに検証対象のスキーマが自動で切り替わり、不要なフィールドの検証が走りません。

エラー表示のタイミング設計

バリデーション分割と同じくらい重要なのが、「いつエラーを見せるか」です。ここを間違えると、技術的には軽くなっても体感は重いまま、ということが起きます。

実務で効果があった設計を整理します。

タイミング適用するバリデーション理由
入力中(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〜350ms8〜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でも同じ戦略が使えるので、フレームワークを問わず応用が利きます。

株式会社ファストコーディングでは、こうしたフォーム最適化やフロントエンドのパフォーマンス改善に数多く取り組んでいます。「フォームの離脱率が高い」「入力中にカクつく」といったお悩みがあれば、お問い合わせフォームからお気軽にご相談ください。次のプロジェクトで、ぜひこのバリデーション分割設計を試してみてください。