Zod + React Hook Form で動的なデータでバリデーションを実装する

こんにちは。 アソビューでフロントエンドエンジニアをやっています、白井です。

今回はフロントエンドをやっていく上で避けられないものの一つである、フォームバリデーションのお話です。

フォームバリデーションライブラリの React Hook Form と、バリデーションスキーマライブラリ Zod の組み合わせは扱いやすく便利なのですが、 バリデーションで使っているデータを後から動的に変化させたいようなシーンでは一手間加える必要があったため、今回はその方法を紹介したいと思います。

やりたいこと

例として、今回は以下の要件のフォームを実装してみます。

  • フォームの項目
    • チケット名
    • 時間帯
    • 枚数
  • バリデーション
    • 時間帯ごとに残り在庫数があり、選択可能枚数は時間帯の最大枚数以下の制限(時間帯を選択するまでは、何枚選択可能かは確定しない)

よって、「枚数選択」の input は後から変化する動的なデータでのバリデーションが必要となります。

実装方法の検討

以上の実装方法を決定するにあたり、いくつか検討してみます。

① Zod のスキーマとは別にバリデーションを行う

Zod のバリデーションスキーマから分離する方法です。 React Hook Form の setError などを利用して、特定の条件でエラーにする方法です。

手っ取り早い方法ではありますが、スキーマ以外のバリデーションが生まれるため、 どんなバリデーションがあるのかスキーマを見ただけではわかりづらくなります。

② React Hook Form にバリデーションに必要な情報を全て持たせる

最終的にデータ送信する情報以外に、バリデーションのみで使用するデータをあらかじめ React Hook Form へセットしておく方法です。 setValue を利用して React Hook Form 側で必要な値を管理することでスキーマから参照が効くので、あとから変化した場合でもバリデーションが可能になります。

ただ、こちらはスキーマオブジェクトにバリデーションで利用する情報が混ざるため、 量が増えるとスキーマが複雑になっていきます。

例として、選択した時間帯の在庫をスキーマに追加する場合は、 以下のように id (時間帯 id)と remainNum (残り枚数)をオブジェクトで管理する形になります。

z.object({
  id: z.number().int().positive("時間帯を選択してください"),
  remainingStock: z.number().int().positive()
})

③ React Hook Form では必要最低限の情報のみを持たせ、バリデーションで利用する動的なデータを zod のスキーマに渡す

React Hook Form で管理するデータはあくまで最終的に必要なもののみで、 バリデーションで必要なデータのみを useState で管理する方法です。

②とは違って不要なデータを React Hook Form で管理しないので構造がシンプルに保てます。 setValue の代わりに useState の setter を使うので、手間的な面ではあまり変わりません。

今回は、

  • バリデーションはすべてスキーマにまとめたい
  • スキーマに最低限の情報だけ持たせたい(バリデーションで使う値は持たせない)

以上の理由から➂で進めていくことにします。

実際の使用例

それでは実際のコードを作っていきます。 事前に React Hook Form, Zod, Typescript を入れた状態で作っていきましょう。

動的バリデーションのための Zod スキーマ定義

// FormContext 型定義
type FormContext = {
  remainingStock: number | null;
  // 将来的に追加のコンテキスト情報をここに追加
};

// 動的スキーマ定義
const createSchema = (context: FormContext) => z.object({
  ticketId: z.number().int().positive("チケットを選択してください"),
  timeSlotId: z.number().int().positive("時間帯を選択してください"),
  quantity: z.number().int().min(1, "枚数を選択してください")
}).refine((data) => {
  return context.remainingStock === null || data.quantity <= context.remainingStock;
}, {
  message: "選択した枚数が在庫数を超えています",
  path: ["quantity"]
});

type TicketFormData = z.infer<ReturnType<typeof createSchema>>;

チケット( ticketId )、時間( timeSlotId )、枚数( quantity )を定義しました。

今回の一番の肝は Zod スキーマを関数でラップしたものを定義してる部分で、引数に context を渡せるようにしています。 この context がバリデーションでのみ利用するデータになります。

「選択されたチケットの選択された時間帯」の在庫数を参照し、選択枚数と比較するために context のデータを元にして refine でカスタムバリデーションを定義しています。

state の作成と React Hook Form への設定

次にフォームの初期化と、context に渡す schemaContext を定義します。 そして React Hook Form の resolverzodResolver を指定し、こちらに先ほどのスキーマ関数を渡してあげるだけです。

こちらで引数にセットした schemaContext が変化すれば、スキーマも再定義されます。

  const [schemaContext, setSchemaContext] = useState<FormContext>({ remainingStock: null });
  const { register, handleSubmit, watch, formState: { errors } } = useForm<TicketFormData>({
    resolver: zodResolver(createSchema(schemaContext)),
    defaultValues: {
      ticketId: 0,
      timeSlotId: 0,
      quantity: 1
    }
  });

スキーマへバリデーションで使用するデータ受け渡し

データを schemaContext にセットしてフォームスキーマに渡しておくことで、 先ほどのスキーマから参照できるようになります。

今回の例ではチケット情報、時間帯情報、在庫情報は API の戻り値を参照する想定で、チケットを選択した際に、選択した ticketId を元にして時間帯を取得、 選択した timeSlotId によって時間帯の在庫を取得するというのが一連の流れになります。

データフェッチに useSWR を使用し、 React Hook Form の watch で監視した値が変更されたらデータの取り直しを自動でやってくれるようにしてみます。

最終的にバリデーションに必要なのは在庫情報( stock )だけなので、useEffect を使ってセットします。

  const watchTicketId = watch('ticketId');
  const watchTimeSlotId = watch('timeSlotId');
  
  const { data: tickets, error: ticketsError } = useSWR<Ticket[]>('/api/tickets', ticketsFetcher);
  const { data: timeSlots, error: timeSlotsError } = useSWR<TimeSlot[]>(
    watchTicketId ? `/api/timeSlots?ticketId=${watchTicketId}` : null,
    timeSlotsFetcher
  );
  const { data: stock, error: stockError } = useSWR<Stock, Error>(
    watchTicketId && watchTimeSlotId
      ? `/api/stock?ticketId=${watchTicketId}&timeSlotId=${watchTimeSlotId}`
      : '',
    (url: string) => stockFetcher([url, watchTimeSlotId])
  );

  useEffect(() => {
    if (stock) {
      setSchemaContext({ remainingStock: stock.remain });
    }
  }, [stock]);

完成

最後に、今までのコードを組み合わせて簡単なフォームを実装してみました。

(API は mock で仮定義となっています)

import './TicketForm.css';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import useSWR from 'swr';

// 型定義
type Ticket = {
  id: number;
  name: string;
};

type TimeSlot = {
  id: number;
  name: string;
};

type Stock = {
  remain: number;
};

// FormContext 型定義
type FormContext = {
  remainingStock: number | null;
  // 将来的に追加のコンテキスト情報をここに追加できます
};

// 動的スキーマ定義
const createSchema = (context: FormContext) => z.object({
  ticketId: z.number().int().positive("チケットを選択してください"),
  timeSlotId: z.number().int().positive("時間帯を選択してください"),
  quantity: z.number().int().min(1, "枚数を選択してください")
}).refine((data) => {
  return context.remainingStock === null || data.quantity <= context.remainingStock;
}, {
  message: "選択した枚数が在庫数を超えています",
  path: ["quantity"]
});

type TicketFormData = z.infer<ReturnType<typeof createSchema>>;

const mockTickets: Ticket[] = [
  { id: 1, name: "一般チケット" },
  { id: 2, name: "学生チケット" },
  { id: 3, name: "シニアチケット" },
];

const mockTimeSlots: TimeSlot[] = [
  { id: 1, name: "朝の部" },
  { id: 2, name: "昼の部" },
  { id: 3, name: "夜の部" },
];

// 型安全な fetcher 関数
const ticketsFetcher = async (): Promise<Ticket[]> => {
  await new Promise(resolve => setTimeout(resolve, 100));
  return mockTickets;
};

const timeSlotsFetcher = async (): Promise<TimeSlot[]> => {
  await new Promise(resolve => setTimeout(resolve, 100));
  return mockTimeSlots;
};

const stockFetcher = async ([url, timeSlotId]: [string, number]): Promise<Stock> => {
  await new Promise(resolve => setTimeout(resolve, 100));
  // TimeSlotのIDに基づいて動的に在庫数を生成
  const stockMap: { [key: number]: number } = {
    1: 15,  // 朝の部
    2: 20,  // 昼の部
    3: 10,  // 夜の部
  };
  return { remain: stockMap[timeSlotId] || 0 };
};

const TicketForm: React.FC = () => {
  const [schemaContext, setSchemaContext] = useState<FormContext>({ remainingStock: null });


  const { register, handleSubmit, watch, formState: { errors } } = useForm<TicketFormData>({
    resolver: zodResolver(createSchema(schemaContext)),
    defaultValues: {
      ticketId: 0,
      timeSlotId: 0,
      quantity: 1
    }
  });

  const watchTicketId = watch('ticketId');
  const watchTimeSlotId = watch('timeSlotId');

  const { data: tickets, error: ticketsError } = useSWR<Ticket[]>('/api/tickets', ticketsFetcher);
  const { data: timeSlots, error: timeSlotsError } = useSWR<TimeSlot[]>(
    watchTicketId ? `/api/timeSlots?ticketId=${watchTicketId}` : null,
    timeSlotsFetcher
  );
  const { data: stock, error: stockError } = useSWR<Stock, Error>(
    watchTicketId && watchTimeSlotId
      ? `/api/stock?ticketId=${watchTicketId}&timeSlotId=${watchTimeSlotId}`
      : '',
    (url: string) => stockFetcher([url, watchTimeSlotId])
  );

  useEffect(() => {
    if (stock) {
      setSchemaContext({ remainingStock: stock.remain });
    }
  }, [stock]);

  const onSubmit = (data: TicketFormData) => {
    console.log(data);
  };

  return (
    <div className="form-container">
      <h2 className="form-title">チケット予約フォーム</h2>
      {(ticketsError || timeSlotsError || stockError) && <p className="error-message">データの取得に失敗しました。</p>}
      <form onSubmit={handleSubmit(onSubmit)}>
        <div className="form-group">
          <label htmlFor="ticketId">チケット種類</label>
          <select id="ticketId" {...register('ticketId', { valueAsNumber: true })} className="form-select">
            <option value="">選択してください</option>
            {tickets?.map(ticket => (
              <option key={ticket.id} value={ticket.id}>{ticket.name}</option>
            ))}
          </select>
          {errors.ticketId && <span className="error-message">{errors.ticketId.message}</span>}
        </div>

        <div className="form-group">
          <label htmlFor="timeSlotId">時間帯</label>
          <select id="timeSlotId" {...register('timeSlotId', { valueAsNumber: true })} className="form-select">
            <option value="">選択してください</option>
            {timeSlots?.map(slot => (
              <option key={slot.id} value={slot.id}>{slot.name}</option>
            ))}
          </select>
          {errors.timeSlotId && <span className="error-message">{errors.timeSlotId.message}</span>}
        </div>

        <div className="form-group">
          <label htmlFor="quantity">枚数</label>
          <input
            id="quantity"
            type="number"
            {...register('quantity', { valueAsNumber: true })}
            min={1}
            className="form-input"
          />
          {errors.quantity && <span className="error-message">{errors.quantity.message}</span>}
        </div>

        {stock && (
          <p className="info-message">選択中の時間帯の残り座席数: {stock.remain}</p>
        )}

        <button type="submit" className="submit-button">予約する</button>
      </form>
    </div>
  );
};

export default TicketForm;

こちらの方法で、 選択値によって API レスポンスから返ってきた在庫以上の枚数を選択できなくなりました!

まとめ

今回の方法で、スキーマ構造を最低限シンプルに保ちつつ、 動的なバリデーションを実現することができました。

ですが watch などを使用している分、コンポーネントのレンダリングが発生してしまいます。 React Hook Form では再レンダリングを最小限に抑えることがメリットの一つであるため、 そのあたりのコストを鑑みた上で使っていただけると幸いです。

最後に

アソビューではより良いプロダクトを世の中に届けられるよう共に挑戦していくエンジニアを募集しています。 カジュアル面談も実施していますので、お気軽にエントリーしていただければと思います。

www.asoview.co.jp