logo

下書き保存機能付きのフォームを実装するサンプル

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

前提

フォーム及びバリデーションのライブラリはReactHookFormZodを利用しています。

本題

皆さんはフォーム画面を実装するときに、「下書き保存が欲しい」となったら、どのように実装しますか?

フォーム画面を実装していると、よくあるのが「下書き保存機能が欲しい」という要望。実際の開発現場では、「途中まで入力して保存しておきたい」というニーズは少なくありません。

今回は、ReactHookFormZodを組み合わせて、「下書き保存」と「登録」ボタンの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のスキーマを分ける
  • ReactHookFormwatch()を使って、登録用の手動バリデーションを行う
  • 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の設計には意外と工夫が必要になります。

ReactHookFormZodを活用すれば、こうした複雑な要件にも対応しやすくなります。ぜひ今回のコードをベースに、より良いフォーム体験を提供してみてください!

参照