下書き保存機能付きのフォームを実装するサンプル
2025-04-12
14 days ago
開発環境
- react 19.1.0
- next 15.2.4
- @hookform/resolvers 5.0.1
- react-hook-form 7.55.0
- zod 3.24.2
前提
フォーム及びバリデーションのライブラリはReactHookFormとZodを利用しています。
本題
皆さんはフォーム画面を実装するときに、「下書き保存が欲しい」となったら、どのように実装しますか?
フォーム画面を実装していると、よくあるのが「下書き保存機能が欲しい」という要望。実際の開発現場では、「途中まで入力して保存しておきたい」というニーズは少なくありません。
今回は、ReactHookFormとZodを組み合わせて、「下書き保存」と「登録」ボタンの2つを持つフォームを実装する方法をご紹介します。
要件と構成
今回の要件は以下の通りです。
- フォーム画面は1つ
- ボタンは「下書き保存」と「登録」の2つ
- バリデーションルールは「登録」と「下書き保存」で異なる
これに対応するために、Zodのスキーマを2種類用意し、用途に応じて使い分ける設計にしています。
ステップ1:シンプルなフォーム
まずは下書き保存のない、基本的なフォームの実装です。
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { SubmitHandler, useForm } from 'react-hook-form';
import { z } from 'zod';
// フォームのバリデーションスキーマを定義
const taskFormSchema = z.object({
title: z
.string()
.min(1, 'タイトルは必須です')
.max(100, 'タイトルは100文字以内で入力してください'),
content: z
.string()
.min(1, '内容は必須です')
.max(1000, '内容は1000文字以内で入力してください'),
isDone: z.boolean(),
});
// フォームの型を定義
type TaskFormValues = z.infer<typeof taskFormSchema>;
export const SampleForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TaskFormValues>({
resolver: zodResolver(taskFormSchema),
defaultValues: {
title: '',
content: '',
isDone: false,
},
});
// フォーム送信時の処理
const onSubmit: SubmitHandler<TaskFormValues> = (data) => {
console.log(data);
// ここでAPI呼び出しなどの処理を実装
};
return (
<div className="mx-auto w-full p-6">
<h2 className="mb-6 text-2xl font-bold">タスク作成</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* タイトルフィールド */}
<div>
<label htmlFor="title" className="mb-1 block text-sm font-medium">
タイトル
</label>
<input
id="title"
type="text"
className="w-full rounded-md border px-3 py-2"
{...register('title')}
/>
{errors.title && (
<p className="mt-1 text-sm text-red-500">{errors.title.message}</p>
)}
</div>
{/* 内容フィールド */}
<div>
<label htmlFor="content" className="mb-1 block text-sm font-medium">
内容
</label>
<textarea
id="content"
className="w-full rounded-md border px-3 py-2"
rows={4}
{...register('content')}
/>
{errors.content && (
<p className="mt-1 text-sm text-red-500">
{errors.content.message}
</p>
)}
</div>
{/* 完了チェックボックス */}
<div className="flex items-center">
<input
id="isDone"
type="checkbox"
className="size-4"
{...register('isDone')}
/>
<label htmlFor="isDone" className="ml-2 text-sm font-medium">
完了
</label>
</div>
{/* 送信ボタン */}
<button
type="submit"
className="w-full rounded-md bg-gray-500 px-4 py-2 text-white hover:bg-gray-600"
>
保存
</button>
</form>
</div>
);
};
ステップ2:「下書き保存」と「登録」の両対応フォーム
次に、下書き保存にも対応したフォームにアップグレードしていきます。
ポイントは以下の通り:
- 登録用と下書き保存用で Zodのスキーマを分ける
- ReactHookFormのwatch()を使って、登録用の手動バリデーションを行う
- ZodのエラーをReactHookFormに手動で渡すユーティリティ関数を作る
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { setManualZodErrors } from '@/utils/set-manual-zod-errors';
// フォームのバリデーションスキーマを定義
// 登録用
const taskFormSchema = z.object({
title: z
.string()
.min(1, 'タイトルは必須です')
.max(100, 'タイトルは100文字以内で入力してください'),
content: z
.string()
.min(1, '内容は必須です')
.max(1000, '内容は1000文字以内で入力してください'),
isDone: z.boolean(),
});
// 下書き保存用
const taskFormDraftSchema = z.object({
title: z
.string()
.max(100, 'タイトルは100文字以内で入力してください')
.optional(),
content: z
.string()
.max(1000, '内容は1000文字以内で入力してください')
.optional(),
isDone: z.boolean().optional(),
});
// フォームの型を定義
// 登録用
type TaskFormValues = z.infer<typeof taskFormSchema>;
// 下書き保存用
type TaskFormDraftValues = z.infer<typeof taskFormDraftSchema>;
export const SampleForm = () => {
const {
register,
handleSubmit,
watch,
setError,
formState: { errors },
} = useForm<TaskFormDraftValues>({
// 下書き保存用を使用
resolver: zodResolver(taskFormDraftSchema),
defaultValues: {
title: '',
content: '',
isDone: false,
},
});
// 下書き保存の処理
const onSaveDraft = async (data: TaskFormDraftValues) => {
try {
console.log('下書き保存:', data);
} catch (error) {
console.error(error);
}
};
// 登録の処理
const onSubmit = async (data: TaskFormValues) => {
try {
console.log('登録:', data);
} catch (error) {
console.error(error);
}
};
return (
<div className="mx-auto w-full p-6">
<h2 className="mb-6 text-2xl font-bold">タスク作成</h2>
<form onSubmit={handleSubmit(onSaveDraft)} className="space-y-4">
{/* タイトルフィールド */}
<div>
<label htmlFor="title" className="mb-1 block text-sm font-medium">
タイトル
</label>
<input
id="title"
type="text"
className="w-full rounded-md border px-3 py-2"
{...register('title')}
/>
{errors.title && (
<p className="mt-1 text-sm text-red-500">{errors.title.message}</p>
)}
</div>
{/* 内容フィールド */}
<div>
<label htmlFor="content" className="mb-1 block text-sm font-medium">
内容
</label>
<textarea
id="content"
className="w-full rounded-md border px-3 py-2"
rows={4}
{...register('content')}
/>
{errors.content && (
<p className="mt-1 text-sm text-red-500">
{errors.content.message}
</p>
)}
</div>
{/* 完了チェックボックス */}
<div className="flex items-center">
<input
id="isDone"
type="checkbox"
className="size-4"
{...register('isDone')}
/>
<label htmlFor="isDone" className="ml-2 text-sm font-medium">
完了
</label>
</div>
{/* ボタングループ */}
<div className="flex gap-5 pt-5">
<button
type="submit"
className="w-full rounded-md bg-gray-500 px-4 py-2 text-white hover:bg-gray-600"
>
下書き保存
</button>
<button
type="button"
onClick={async () => {
// 登録
const values = watch();
// 登録用のスキーマでバリデーション
const result = taskFormSchema.safeParse(values);
if (!result.success) {
// ! Zod のエラーを ReactHookForm のエラーとして手動でセット
setManualZodErrors({
zodErrors: result.error.format(),
setError,
fieldOrder: ['title', 'content', 'isDone'],
});
} else {
try {
// 登録の処理
onSubmit(result.data);
} catch (error) {
console.error(error);
}
}
}}
className="w-full rounded-md bg-gray-500 px-4 py-2 text-white hover:bg-gray-600"
>
登録
</button>
</div>
</form>
</div>
);
};
setManualZodErrorsで手動でエラーをセットしています。中身こちら。
import type { FieldValues, Path, UseFormSetError } from 'react-hook-form';
import type { ZodFormattedError } from 'zod';
type ErrorMessage = string | undefined;
type SetManualZodErrorsProps<TFormValues extends FieldValues> = {
zodErrors: ZodFormattedError<TFormValues>;
setError: UseFormSetError<TFormValues>;
fieldOrder: Path<TFormValues>[];
options?: {
defaultMessage?: string;
shouldFocus?: boolean;
};
};
/**
* Zodのエラーオブジェクトからエラーメッセージを取得する
*/
const getNestedErrorMessage = <TFormValues extends FieldValues>(
errors: ZodFormattedError<TFormValues>,
path: string[],
): ErrorMessage => {
const errorObject = path.reduce<unknown>((obj, key) => {
if (obj && typeof obj === 'object' && key in obj) {
return (obj as Record<string, unknown>)[key];
}
return null;
}, errors);
if (
errorObject &&
typeof errorObject === 'object' &&
'_errors' in errorObject
) {
const zodError = errorObject as { _errors?: string[] };
// _errorsが存在する場合は、空配列でもデフォルトメッセージを使用する
return zodError._errors?.[0] ?? '';
}
return undefined;
};
/**
* ZodのエラーをReact Hook Formのエラーとしてセットする
*/
export const setManualZodErrors = <TFormValues extends FieldValues>({
zodErrors,
setError,
fieldOrder,
options = {},
}: SetManualZodErrorsProps<TFormValues>): void => {
const { defaultMessage = '入力に誤りがあります', shouldFocus = true } =
options;
// フィールドの順序を逆順にして処理(最後のエラーにフォーカスを当てるため)
const reversedFields = [...fieldOrder].reverse();
reversedFields.forEach((field) => {
const fieldPath = field.split('.');
const errorMessage = getNestedErrorMessage(zodErrors, fieldPath);
// errorMessageが空文字列(_errorsが空配列の場合)またはundefinedでない場合にsetErrorを呼び出す
if (errorMessage !== undefined) {
setError(
field,
{
type: 'manual',
message: errorMessage || defaultMessage,
},
{
shouldFocus,
},
);
}
});
};
実装のポイントまとめ
- フォームロジックを1つに集約して、スキーマだけを切り替える設計にすると保守しやすい。
- バリデーションをスキーマで分けることで、用途に応じた柔軟な入力チェックが可能になる。
- ReactHookForm + Zodの組み合わせは、バリデーションをカスタマイズしたい場面でもとても強力。
さいごに
今回紹介した「下書き保存付きフォーム」は、実務でよく求められる要件の1つです。シンプルに見えて、バリデーションやUXの設計には意外と工夫が必要になります。
ReactHookFormとZodを活用すれば、こうした複雑な要件にも対応しやすくなります。ぜひ今回のコードをベースに、より良いフォーム体験を提供してみてください!