React Hook Form と Valibot を用いたフォーム実装において、メールアドレスの重複チェックなどサーバー側の非同期バリデーションを行うと、入力ごとに API を呼び出してしまい、パフォーマンスやコストに悪影響を与えることがあります。こうした問題に対して有効なのが「デバウンス(debounce)」です。ユーザーの入力が停止してから一定時間待機してから検証を実行することで、不要なリクエストを削減できます。
本記事では、React Hook Form と Valibot にデバウンスを組み込む方法を実装例とともに示します。中心となるのはカスタムフック useDebouncedValidator と、これを利用するフォームフック useSignupForm です。サンプルリポジトリも用意しているため、手元で動作を確認しながら理解を深めてください。

フォームのリアルタイムバリデーションで入力ごとにサーバーへ問い合わせを行う(特に onChange ごとに発行する実装)と、次のような問題が生じます。
デバウンスは、指定した待機時間(例: 500ms)だけ入力の停止を待ってから検証処理を実行することで、上記の問題を緩和する手法です。
デバウンスのコアとなるカスタムフックです。チェック関数を遅延実行してくれる便利な仕組みです。
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
type ValidateResult = boolean | Promise<boolean>;
type ValidateFn<T> = (value: T) => ValidateResult;
type Options<T> = {
delay?: number;
negate?: boolean;
defaultValue?: T | undefined;
maxCacheSize?: number;
};
export function useDebouncedValidator<T = string>(
validate: ValidateFn<T>,
options: Options<T> = {}
) {
const memoizedOptions = useMemo(() => options, [options]);
const { delay = 500, negate = false, defaultValue, maxCacheSize = 100 } = memoizedOptions;
const [lastResult, setLastResult] = useState(false);
const cacheRef = useRef(new Map<T, boolean>());
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingResolversRef = useRef<Array<(result: boolean) => void>>([]);
const pendingValueRef = useRef<T | undefined>(undefined);
const flushResolvers = (result: boolean) => {
const resolvers = pendingResolversRef.current.splice(0);
resolvers.forEach((resolve) => resolve(result));
};
const performValidation = useCallback(
async (currentValue: T) => {
try {
const rawResult = await validate(currentValue);
const result = Boolean(rawResult);
// Limit cache size
if (cacheRef.current.size >= maxCacheSize) {
const firstKey = cacheRef.current.keys().next().value;
if (firstKey !== undefined) {
cacheRef.current.delete(firstKey);
}
}
cacheRef.current.set(currentValue, result);
const finalResult = negate ? !result : result;
if (lastResult !== finalResult) {
setLastResult(finalResult);
}
flushResolvers(finalResult);
} catch {
const result = false;
// Limit cache size
if (cacheRef.current.size >= maxCacheSize) {
const firstKey = cacheRef.current.keys().next().value;
if (firstKey !== undefined) {
cacheRef.current.delete(firstKey);
}
}
cacheRef.current.set(currentValue, result);
const finalResult = negate ? !result : result;
if (lastResult !== finalResult) {
setLastResult(finalResult);
}
flushResolvers(finalResult);
}
},
[validate, negate, lastResult, maxCacheSize]
);
const debouncedValidator = useCallback(
(value: T): Promise<boolean> => {
if (Object.is(value, defaultValue)) {
return Promise.resolve(true);
}
if (cacheRef.current.has(value)) {
const cachedResult = cacheRef.current.get(value)!;
return Promise.resolve(negate ? !cachedResult : cachedResult);
}
return new Promise<boolean>((resolve) => {
pendingResolversRef.current.push(resolve);
pendingValueRef.current = value;
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(async () => {
timerRef.current = null;
const currentValue = pendingValueRef.current!;
if (currentValue !== value) {
return;
}
await performValidation(currentValue);
}, delay);
});
},
[delay, defaultValue, performValidation, negate]
);
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
flushResolvers(false);
};
}, []);
return { debouncedValidator, lastResult } as const;
}React Hook Form と Valibot を組み合わせたサインアップフォーム用のフックです。デバウンスを活かしてメールチェックを実装しています。
'use client';
import { valibotResolver } from '@hookform/resolvers/valibot';
import { useForm } from 'react-hook-form';
import * as v from 'valibot';
import { useDebouncedValidator } from './use-debounced-validator';
export const inputSchema = (debouncedValidator: (value: string) => Promise<boolean>) =>
v.objectAsync({
name: v.pipe(v.string(), v.minLength(1, 'This field is required')),
email: v.pipeAsync(
v.string(),
v.minLength(1, 'This field is required'),
v.email('Please enter a valid email format'),
v.checkAsync(debouncedValidator, 'This email is not available')
),
});
export type Inputs = v.InferOutput<ReturnType<typeof inputSchema>>;
export function useSignupForm() {
const isValidEmail = async (value: string) => {
const url = new URL('/api', window.location.origin);
url.searchParams.set('email', value);
const response = await fetch(url);
const data = (await response.json()) as { result: boolean };
return !data.result;
};
const { debouncedValidator } = useDebouncedValidator<string>(isValidEmail);
const schema = inputSchema(debouncedValidator);
const form = useForm({
mode: 'all',
resolver: valibotResolver(schema, {}, { mode: 'async' }),
defaultValues: { name: '', email: '' },
});
return form;
}Valibot、React Hook Form、useDebouncedValidator の3つがどのようにしてデバウンスされた非同期バリデーションを実現しているかを説明します。
React Hook Form は、フォームの状態管理と検証のトリガーを担当します。useForm フックで作成された form オブジェクトは、コンポーネントで register や handleSubmit などのメソッドを提供します。検証は mode: 'all' で全フィールドに対してリアルタイムに行われ、resolver オプションで外部のバリデーションライブラリ(ここでは Valibot)を統合します。
valibotResolver は、React Hook Form と Valibot を橋渡しするアダプターです。useForm の resolver に valibotResolver(schema) を渡すことで、フォームの入力値が Valibot のスキーマに基づいて検証されます。スキーマは inputSchema で定義され、v.objectAsync を使って非同期バリデーションをサポートします。
Valibot の v.checkAsync(debouncedValidator, 'This email is not available') は、非同期チェック関数を受け取り、Promise を返す検証を行います。ここで debouncedValidator (useDebouncedValidator から提供される関数)を渡すことで、通常の即時API呼び出しではなく、デバウンスされた検証が可能になります。
useDebouncedValidator は、渡された isValidEmail 関数をラップし、500ms の遅延後にのみ実行します。これにより、ユーザーの入力が停止するまでAPI呼び出しを待機します。onChange イベントで検証をトリガーします。valibotResolver が Valibot スキーマを実行し、checkAsync を呼び出します。checkAsync が debouncedValidator を実行しますが、useDebouncedValidator は即座に実行せず、タイマーをセットします。isValidEmail がAPIを呼び出し、結果を取得します。この連携により、Valibot の型安全なスキーマ定義、React Hook Form の高性能なフォーム管理、useDebouncedValidator のデバウンス機能がシームレスに統合され、パフォーマンスとユーザー体験を両立したフォームが実現されます。
デバウンスを導入することで、フォームの検証に伴う不要な API 呼び出しを削減できます。結果として、パフォーマンス、ユーザー体験、および運用コストの改善が期待できます。