🖼️

React Hook Form + zodで実装されたフォームに画像アップロード用のinputを追加する

2024/03/11に公開

はじめに

本稿はReactHookForm + zodで作られたフォームに画像アップロード用のinputを追加する方法についての記事です。

zodによるschema定義 + 以下の3つのフォーム実装の順に記述しています。

  • ライブラリを使用せずに<input type="file" />で実装
  • react-dropzoneを使用して実装
  • FilePondを使用して実装

また、本稿のコードはCodeSandboxで公開しています。

https://codesandbox.io/p/devbox/2wpt3z?file=%2Fsrc%2FApp.tsx&embed=1

zodでスキーマを定義する

zodには組み込みのFile型が存在しないため、customを使用します。

customはzodでサポートしていない型のスキーマを作成するための機能で、型引数として渡した型に沿った独自のスキーマを定義することができます。<input type="file" />で設定されたファイルはFileList型になるのでこの型を渡してスキーマを定義します。

import * as z from "zod";

export const imagesSchema = z.object({
  images: z.custom<FileList>();
});

export type ImagesSchema = z.infer<typeof imagesSchema>;

このままではバリデーションがないのでrefineでカスタムバリデーションを定義していきます。ここではよく検証されそうないくつかのバリデーションを追加しました。

import * as z from "zod";

const IMAGE_TYPES = ["image/png", "image/jpg"];
const IMAGE_SIZE_LIMIT = 500_000;

export const imagesSchema = z.object({
  images: z
    .custom<FileList>()
+    .refine((files) => 0 < files.length, {
+      message: "画像ファイルの添付は必須です",
+    })
+    .refine((files) => 0 < files.length && files.length < 6, {
+      message: "添付できる画像ファイルは5枚までです",
+    })
+    .refine(
+      (files) =>
+        Array.from(files).every((file) => file.size < IMAGE_SIZE_LIMIT),
+      { message: "添付できる画像ファイルは5MBまでです" },
+    )
+    .refine(
+      (files) =>
+        Array.from(files).every((file) => IMAGE_TYPES.includes(file.type)),
+      { message: "添付できる画像ファイルはjpegかpngです" },
+    ),
});

export type ImagesSchema = z.infer<typeof imagesSchema>;

以降ではここで実装したスキーマを使用して、バリデーション付きのフォームを実装します。
ライブラリを使用する/しないに関わらず、スキーマに変更はありません。

<input type="file" />による実装

ライブラリを使用せずに<input type="file" />で実装する場合、registerを渡してあげるだけでファイルのバリデーション・アップロードは簡単にできます。

特別な要件がなければ一番早く実装できますが、ドラッグ&ドロップによる画像アップロード・画像プレビュー機能などが必要な場合は自分で実装する必要があります。

画像のアップロード

import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { imagesSchema } from "../schema";

import type { ImagesSchema } from "../schema";

export const NativeInputForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ImagesSchema>({
    resolver: zodResolver(imagesSchema),
  });

  const onSubmit = (data: ImagesSchema) => console.log(data);

  return (
      <form className="form" onSubmit={handleSubmit(onSubmit)}>
        <input
          type="file"
          accept=".png, .jpg"
          multiple
          {...register("images")}
        />
        {errors.images && (
          <span className="form-error-message">{errors.images.message}</span>
        )}
        <button type="submit">送信</button>
      </form>
  );
};

画像のプレビュー

画像のプレビューはアップロードされた画像のパスを<img>に渡してあげることで実現できます。具体的にはFileListオブジェクトから取り出した対象のFilecreateObjectURLに渡してURL文字列を生成します。

https://developer.mozilla.org/ja/docs/Web/API/URL/createObjectURL_static

+ import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { imagesSchema } from "../schema";

import type { ImagesSchema } from "../schema";

export const NativeInputForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ImagesSchema>({
    resolver: zodResolver(imagesSchema),
  });

+ const [previewUrls, setPreviewUrls] = useState<string[]>([]);

+ const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+   const files = event.target.files;
+
+    if (files) {
+      const urls = Array.from(files).map((file) => {
+        return URL.createObjectURL(file);
+      });
+      setPreviewUrls(urls);
+    }
+  };

  const onSubmit = (data: ImagesSchema) => console.log(data);

  return (
      <form className="form" onSubmit={handleSubmit(onSubmit)}>
        <input
          type="file"
          accept=".png, .jpg"
          multiple
+         {...register("images", { onChange })}
        />
+        {previewUrls.map((url, index) => (
+          <img key={index} width="100" height="100" src={url} alt="Preview" />
+        ))}
        {errors.images && (
          <span className="form-error-message">{errors.images.message}</span>
        )}
        <button type="submit">送信</button>
      </form>
  );
};

react-dropzoneによる実装

react-dropzoneはファイルアップロードのためのドラッグ&ドロップゾーンを作るためのシンプルなライブラリです。ドラッグ&ドロップによるファイルアップロードを実現するためのシンプルなフック(useDropzone)と、そのフックを薄くラップしたコンポーネント(Dropzone)のみを提供しています。

https://react-dropzone.js.org/

スタイルは提供されておらず、描画されたドロップゾーンに対して自由にスタイリングを行うことができるのでUIの自由度が高いです。

プレビュー機能は提供されていないので、プレビューしたい場合は↑と同じような方法で自分で実装する必要があります。

画像のアップロード + プレビュー

プレビューの実装については<input type="file">の場合と同様なので、まとめて記述します。

import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { imagesSchema } from "../schema";
import { useDropzone } from "react-dropzone";

import type { ImagesSchema } from "../schema";

export const ReactDropzoneForm = () => {
  const {
    handleSubmit,
    formState: { errors },
    setValue,
  } = useForm<ImagesSchema>({
    resolver: zodResolver(imagesSchema),
  });

  const [previewUrls, setPreviewUrls] = useState<string[]>([]);

  const onDrop = (acceptFiles: File[]) => {
    if (acceptFiles.length === 0) return;

    // NOTE: onDropの引数はFile[]なので、DataTransferを使用してFileListに変換する
    const dataTransfer = new DataTransfer();

    for (const file of acceptFiles) {
      dataTransfer.items.add(file);
    }

    setValue("images", dataTransfer.files);
    setPreviewUrls(acceptFiles.map((file) => URL.createObjectURL(file)));
  };

  const { getRootProps, getInputProps } = useDropzone({
    accept: {
      "image/png": [".png"],
      "image/jpeg": [".jpg"],
    },
    onDrop,
  });

  const onSubmit = (data: ImagesSchema) => console.log(data);

  return (
      <form className="form" onSubmit={handleSubmit(onSubmit)}>
        <div {...getRootProps({ className: "form-dropzone" })}>
          <input {...getInputProps()} />
          <p>ファイル選択 または ドラッグ&ドロップ</p>
        </div>
        {previewUrls.map((url, index) => (
          <img key={index} width="100" height="100" src={url} alt="Preview" />
        ))}
        {errors.images && (
          <span className="form-error-message">{errors.images.message}</span>
        )}
        <button type="submit">送信</button>
      </form>
  );
};

FilePondによる実装

FilePondは画像に限らずあらゆるものをアップロードできるJavaScriptライブラリです。

https://pqina.nl/filepond/

react-dropzoneと違いinputを作るためのライブラリではなく、FilePond単体でファイルアップローダーとして機能させることができます。React専用のラッパーコンポーネントも提供されておりreact-hook-formのControllerを使えば簡単にフォームに組み込むことができます。

リッチなスタイルがデフォルトで提供されておりモダンな見た目でかっこいいのですが、実現したいUIがある場合はスタイルのオーバーライドが少し面倒です(ドキュメントにも「あんまり元のスタイルオーバーライドして、アップグレードできなくなっても知らんよ!」という旨の注意があります)。

Overriding too much styles might make upgrading to a new version difficult and could impact accessibility.

画像のアップロード

import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { imagesSchema } from "../schema";
import { FilePond, registerPlugin } from "react-filepond";
import "filepond/dist/filepond.min.css";
import FilePondPluginImagePreview from "filepond-plugin-image-preview";
import "filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css";

import type { ImagesSchema } from "../schema";

registerPlugin(FilePondPluginImagePreview);

export const FilePondForm = () => {
  const {
    handleSubmit,
    formState: { errors },
    control,
  } = useForm<ImagesSchema>({
    resolver: zodResolver(imagesSchema),
  });

  const onSubmit = (data: ImagesSchema) => console.log(data);

  return (
      <form className="form" onSubmit={handleSubmit(onSubmit)}>
        <Controller
          name="images"
          control={control}
          render={({ field: { onChange, name } }) => (
            <FilePond
              name={name}
              allowMultiple={true}
              storeAsFile={true}
              credits={false}
              labelIdle='<span class="filepond--label-action"> ファイル選択 </span> または ドラッグ&ドロップ'
              onupdatefiles={(files) => {
                const dataTransfer = new DataTransfer();
                files.forEach((file) => {
                  dataTransfer.items.add(file.file);
                });
                onChange(dataTransfer.files);
              }}
            />
          )}
        />
        {errors.images && (
          <span className="form-error-message">{errors.images.message}</span>
        )}
        <button type="submit">送信</button>
      </form>
  );
};

画像のプレビュー

FilePondは提供されるプラグインを追加することで、欲しい機能を簡単にオプトインすることができます。

https://pqina.nl/filepond/plugins/

プレビュー機能もプラグインが提供されており、以下のように追加できます。

import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { imagesSchema } from "../schema";
import { FilePond, registerPlugin } from "react-filepond";
import "filepond/dist/filepond.min.css";
+ import FilePondPluginImagePreview from "filepond-plugin-image-preview";
+ import "filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css";

import type { ImagesSchema } from "../schema";

+ registerPlugin(FilePondPluginImagePreview);

// コンポーネントの実装は同じなので省略
};

まとめ

ネイティブの<input>は簡単に実装ができますが、最近だとドラッグ&ドロップによるファイル追加ができないフォームはあまり見かけない気がします。

FilePondのような単体でファイルアップローダーを兼ねるライブラリはいくつかありますが、今回のように単にドラッグ&ドロップ + プレビューだけあればいいようなケースでは少しオーバーで個人的にはreact-dropzoneくらいが丁度いいと感じました。

  • ライブラリを使用せずに<input type="file" />のみで実装

    • 特別な要件がない場合は最も簡単
    • ドラッグ&ドロップやプレビューなど追加の機能が必要な場合は、自分で実装する必要があるのでライブラリの使用が視野に入る
  • react-dropzone

    • ドラッグ&ドロップ機能だけを提供する
    • 類似ライブラリの中では最も使われてそうだが、最終更新が2年前で止まっているので覚悟
  • FilePond

    • ドラッグ&ドロップ機能以外にも多彩な機能を提供する
    • UIのカスタマイズは少し面倒かも

参考リンク

https://github.com/colinhacks/zod/issues/387

Discussion