Next.js で react-dropzone を使ってファイルアップローダーを作ってみよう

この記事は ANDPAD Advent Calendar 2023 の 10日目の記事です。

どうもこんにちは。フロントエンドエンジニアの蓮子です。今回は、Next.js におけるドラッグ & ドロップできるファイルアップローダーの作り方の紹介です。 使用するライブラリは react-dropzone です。「Next.js ファイルアップロード ドラッグアンドドロップ」とググると数多く紹介されていることから、利用者が多くメジャーなものな気がします。

とはいえ、いい感じのデモが見つかりづらかったので、自分で作って記事にしてみようと思いました。

今回作りたいもの

ファイルアップローダー
今回のゴールのビジュアルはこちらです。よく見るUIですね。 それでは具体的な作り方を紹介していきます。

開発環境

  • node 20.9.0
  • Next.js 13.1.6
  • react-dropzone 14.2.3

仕様

  • 複数ファイル同時アップロード可
  • 1ファイルのサイズ上限は50MB
  • 拡張子は JPEG、PNG、PDF のみ許可
  • 合計アップロード数の上限は10ファイル
  • アップロードしたものが下にリストで表示されていく
  • アップロード中のリストはスピナーを表示

実装の順序

順序立てて実装するとだいたい下記のようになると思います。

  1. UIの静的実装
  2. ファイルのドロップとバリデーション処理
  3. ファイルのアップロードと進行状態の管理

1. UIの静的実装

<div className={styles.wrapper}>
  <div>
    <div className={`${styles.file_upload} ${setDropZoneStyle()}`}>
      <p className={styles.file_name}>
        {isDragAccept
          ? "ファイルをアップロードします。"
          : isDragReject
            ? "エラー"
            : "ファイルを登録してください。"}
      </p>
      <p>
        {isDragReject
          ? "このファイル形式のアップロードは許可されていません。"
          : "ファイルを選択するか、ドラッグアンドドロップしてください。"}
      </p>
      <button disabled={isDragReject}>ファイルを選択</button>
    </div>
    <p className={styles.note}>
      複数のファイルを選択できます。pdf, png, jpg, jpeg
      ファイルを選択できます。
    </p>
    <p className={styles.caution}>※1ファイルの最大サイズは50MBです</p>
  </div>
  {currentShowFiles && (
    <aside>
      <ul className={styles.file}>
        {currentShowFiles.map((item, index) => (
          <li key={index} className={styles.file_list}>
            {item.isUploaded ? (
              <div className={styles.file_item}>
                <div className={styles.file_item_type}>
                  <span className={styles.icon_file}>
                    <File />
                  </span>
                </div>
                <div className={styles.file_item_body}>
                  <p className={styles.file_item_name}>{item.file.name}</p>
                </div>
                <button
                  type="button"
                  className={styles.file_item_trash}
                  onClick={() => {
                    removeFile(index);
                  }}
                >
                  <span className={styles.icon_trash}>
                    <Trash />
                  </span>
                </button>
              </div>
            ) : (
              <div className={styles.file_item}>
                <div className={styles.file_item_type}>
                  <Image
                    src="/../public/spinner.gif"
                    width={20}
                    height={20}
                    alt="loading"
                  />
                </div>
                <div className={styles.file_item_body}>
                  <p className={styles.file_item_name}>
                    {item.file.name}をアップロードしています…
                  </p>
                </div>
              </div>
            )}
          </li>
        ))}
      </ul>
    </aside>
  )}
</div>

大まかに、ファイルをドロップできるボックスと、アップロードしたファイルをリスト表示する領域に分けます。 ドロップボックスの方は、禁止されている拡張子をアップしようとした時のエラー表示を条件分岐で記載しておきます。 また、ドラッグ & ドロップ以外でもアップロードできるようにボタンも用意しておきます。 リストの方は、アップロード中の条件分岐を記載しておきます。

2. ファイルのドロップとバリデーション処理

const onDrop = useCallback(
  async (acceptedFiles: File[]) => {
    // ドロップしたファイルの中で、現在表示されているファイルと重複しているもの( filename と size が同じファイル)を除外する。
    const filteringFiles = acceptedFiles.filter(
      (file) =>
        !currentShowFiles?.find(
          (showFile) =>
            file.name === showFile.file.name &&
            file.size === showFile.file.size,
        ),
    );

    // ドロップしたファイルと現在表示されているファイルの合計が 10 を超える場合、追加を許可しない。
    if (filteringFiles.length + currentShowFiles.length > 10) {
      alert("最大10ファイルまでアップロードできます。");
      return;
    }
  },
  [currentShowFiles],
);

const onDropRejected = useCallback((rejectedFiles: FileRejection[]) => {
  rejectedFiles.forEach(({ file, errors }) => {
    errors.forEach(({ code }) => {
      let message = "エラーが発生しました。";
      switch (code) {
        case "file-too-large":
          message = `${file.name} のファイルサイズが大きすぎます。50MB以下のファイルをアップロードしてください。`;
          break;
        case "file-invalid-type":
          message = `${file.name} のファイル形式が許可されていません。許可されているファイル形式は jpg, png, pdf, doc, docx, xls, xlsx, ppt, pptx です。`;
          break;
        default:
          break;
      }
      alert(message);
    });
  });
}, []);

const { getRootProps, getInputProps, isDragAccept, isDragReject } =
  useDropzone({
    onDrop,
    onDropRejected,
    accept: {
      "image/jpeg": [],
      "image/png": [],
      "application/pdf": [],
    },
    maxSize: 50 * 1024 * 1024, // 50MB
  });

まずは dropzone の設定をおこないます。

getRootProps と getInputProps

getRootProps と getInputProps は、ドロップゾーンの HTML 要素に必要なプロパティを提供します。 getRootProps は、ドロップエリアのコンテナに適用されるプロパティを提供し、ドラッグアンドドロップのイベントハンドリングを担います。 戻り値は以下です。

onBlur: ƒ (event)
onClick: ƒ (event)
onDragEnter: ƒ (event)
onDragLeave: ƒ (event)
onDragOver: ƒ (event)
onDrop: ƒ (event)
onFocus: ƒ (event)
onKeyDown: ƒ (event)
ref: {current: null}
tabIndex: 0

getInputProps は、ファイルを添付するための input 要素に適用されるプロパティを提供します。 戻り値は以下です。

accept: undefined
autoComplete: "off"
multiple: true
onChange: ƒ (event)
onClick: ƒ (event)
ref: {current: null}
style: {display: "none"}
tabIndex: -1
type: "file"

それぞれHTMLに追記しておきましょう。

<div className={styles.wrapper}>
  <div>
    <div
      {...getRootProps()}
      className={`${styles.file_upload} ${setDropZoneStyle()}`}
    >
      <input {...getInputProps()} />

isDragAccept と isDragReject

これらは、ドロップエリア上にあるファイルが受け入れられるか、拒否されるかを示すブール値です。 isDragAccept は、ドラッグされているファイルが受け入れ可能な場合に true になります。 isDragReject は、ドラッグされているファイルが拒否されるべき場合(例えば、サポートされていないファイル形式やサイズの場合)に true になります。

その他の設定オプション

onDrop は、ファイルがドロップされた時に実行される関数を指定します。 onDropRejected は、受け入れられなかったファイルに対する処理を定義します。 accept では、どのファイル形式を受け入れるかを指定します。ここでは JPEG、PNG、PDF ファイルを受け入れます。 maxSize では、受け入れるファイルの最大サイズを指定します。この例では、最大50MBのファイルまでを受け入れるように設定しています。

次に onDrop 内の処理を書きます。

onDrop

useCallback フックの使用

useCallback はReactのフックの一つで、特定の依存関係(第二引数)が変更された場合にのみ関数を再生成することを保証します。 今回のケースでは、表示されているファイル(currentShowFiles)が変更された時のみ再生成するようにします。

重複ファイルの除外

ドロップされたファイル(acceptedFiles)から、既に表示されているファイルで名前およびサイズが一致するものを除外します。 これにより、ユーザーが誤って同じファイルを複数回ドロップすることを防ぎます。

ファイル数の制限

フィルタリングされたファイル(filteringFiles)と現在表示されているファイルの合計が10を超える場合、新たなファイルの追加を許可しないようにします。

次は onDropRejected 内の処理を書きます。

onDropRejected

エラー内容に応じて、ユーザーへフィードバックする

onDropRejected は rejectedFiles という引数を受け取ります。これは、受け入れられなかったファイルのリストです。 各ファイルには、なぜ受け入れられなかったのかを示すエラー情報が関連付けられています。 それらを解析して、エラー内容を alert で表示し、ユーザーが理解できるようにします。

次はファイルをサーバーにアップロードする部分の説明です。

3. ファイルのアップロードと進行状態の管理

const onUploadFile = async (file: File) => {
  try {
    setCurrentShowFiles((prevFiles) => [
      ...prevFiles,
      { file, isUploaded: false },
    ]);

    const uploadTime = Math.random() * 9000 + 1000; // 1秒から10秒
    await new Promise((resolve) => setTimeout(resolve, uploadTime));

    setCurrentShowFiles((prevFiles) =>
      prevFiles.map((f) =>
        f.file.name === file.name ? { ...f, isUploaded: true } : f,
      ),
    );
  } catch (error) {
    // ↓ここでエラーに関するユーザーへの通知や処理を行う
    alert(`アップロード中にエラーが発生しました: ${error}`);
  }
};

アップロードの開始

このコードはまず、filteringFiles に含まれるファイル(アップロード可能なファイル)が存在するかを確認します。ファイルが存在する場合、アップロードプロセスを開始します。 Promise.all を使用して、filteringFiles 配列内の各ファイルに対して onUploadFile 関数を並行して実行します。これにより、複数のファイルが同時に効率的にアップロードします。

onDrop 関数の最後に onUploadFile 関数の並行実行の処理を try catch で書きます。 アップロード中にエラーが発生した場合(例えば、ネットワークの問題やサーバー側のエラー)、catch ブロックが実行され、ユーザーに対してエラー通知をします。

    // アップロード可能なファイルが存在する場合、アップロード中のスイッチを true にし、アップロードを開始する
    if (filteringFiles.length) {
      try {
        await Promise.all(filteringFiles.map((file) => onUploadFile(file)));
        // ↓すべてのファイルのアップロードが成功した後の処理を書く
      } catch (error) {
        // ↓ここでエラーに関するユーザーへの通知や処理を行う
        alert(`アップロード中にエラーが発生しました: ${error}`);
      }
    }
  },
  [currentShowFiles],
);

アップロード状態の管理

onUploadFile 関数は、個々のファイルのアップロードを管理します。 関数はまず、アップロードするファイルを setCurrentShowFiles を使用して現在のファイルリストに追加します。この時点で、ファイルは「アップロードされていない(isUploaded: false)」とマークします。

アップロードのシミュレーション

実際のAPIは使えないので、ファイルアップロードのシミュレーションとして、ランダムな時間(1秒から10秒)の遅延を設定しています。この遅延は、実際のネットワーク経由でのアップロードを模倣します。

アップロード完了後の状態更新

アップロードが完了すると、再び setCurrentShowFiles を呼び出し、アップロードされたファイルの状態を「アップロード済み(isUploaded: true)」に更新します。

エラーハンドリング

onUploadFile 関数内でも、アップロード中にエラーが発生した場合にユーザーに通知を行います。

まとめ

完成したコードはこちらです。

import type { NextPage } from "next";
import React, { useState, useCallback } from "react";
import { useDropzone } from "react-dropzone";
import type { FileRejection } from "react-dropzone";
import styles from "../styles/FileUploader.module.css";
import File from "public/file.svg";
import Trash from "public/trash.svg";
import Image from "next/image";

const FileUploader: NextPage = () => {
  const [currentShowFiles, setCurrentShowFiles] = useState<
    { file: File; isUploaded: boolean }[]
  >([]);

  const onUploadFile = async (file: File) => {
    try {
      setCurrentShowFiles((prevFiles) => [
        ...prevFiles,
        { file, isUploaded: false },
      ]);

      const uploadTime = Math.random() * 9000 + 1000; // 1秒から10秒
      await new Promise((resolve) => setTimeout(resolve, uploadTime));

      setCurrentShowFiles((prevFiles) =>
        prevFiles.map((f) =>
          f.file.name === file.name ? { ...f, isUploaded: true } : f,
        ),
      );
    } catch (error) {
      // ↓ここでエラーに関するユーザーへの通知や処理を行う
      alert(`アップロード中にエラーが発生しました: ${error}`);
    }
  };

  const onDrop = useCallback(
    async (acceptedFiles: File[]) => {
      // ドロップしたファイルの中で、現在表示されているファイルと重複しているもの( filename と size が同じファイル)を除外する。
      const filteringFiles = acceptedFiles.filter(
        (file) =>
          !currentShowFiles?.find(
            (showFile) =>
              file.name === showFile.file.name &&
              file.size === showFile.file.size,
          ),
      );

      // ドロップしたファイルと現在表示されているファイルの合計が 10 を超える場合、追加を許可しない。
      if (filteringFiles.length + currentShowFiles.length > 10) {
        alert("最大10ファイルまでアップロードできます。");
        return;
      }

      // アップロード可能なファイルが存在する場合、アップロード中のスイッチを true にし、アップロードを開始する
      if (filteringFiles.length) {
        try {
          await Promise.all(filteringFiles.map((file) => onUploadFile(file)));
          // ↓すべてのファイルのアップロードが成功した後の処理を書く
        } catch (error) {
          // ↓ここでエラーに関するユーザーへの通知や処理を行う
          alert(`アップロード中にエラーが発生しました: ${error}`);
        }
      }
    },
    [currentShowFiles],
  );

  const onDropRejected = useCallback((rejectedFiles: FileRejection[]) => {
    rejectedFiles.forEach(({ file, errors }) => {
      errors.forEach(({ code }) => {
        let message = "エラーが発生しました。";
        switch (code) {
          case "file-too-large":
            message = `${file.name} のファイルサイズが大きすぎます。50MB以下のファイルをアップロードしてください。`;
            break;
          case "file-invalid-type":
            message = `${file.name} のファイル形式が許可されていません。許可されているファイル形式は jpg, png, pdf, doc, docx, xls, xlsx, ppt, pptx です。`;
            break;
          default:
            break;
        }
        alert(message);
      });
    });
  }, []);

  const { getRootProps, getInputProps, isDragAccept, isDragReject } =
    useDropzone({
      onDrop,
      onDropRejected,
      accept: {
        "image/jpeg": [],
        "image/png": [],
        "application/pdf": [],
      },
      maxSize: 50 * 1024 * 1024, // 50MB
    });

  // ドラッグ中のスタイルを設定
  const setDropZoneStyle = () => {
    if (isDragAccept) {
      return styles.is_drag_accept;
    } else if (isDragReject) {
      return styles.is_drag_reject;
    } else {
      return "";
    }
  };

  const removeFile = (index: number) => {
    const filteringFiles = currentShowFiles.filter(
      (_, i) => i !== index,
    );
    setCurrentShowFiles(filteringFiles);
  };

  return (
    <div className={styles.wrapper}>
      <div>
        <div
          {...getRootProps()}
          className={`${styles.file_upload} ${setDropZoneStyle()}`}
        >
          <input {...getInputProps()} />
          <p className={styles.file_name}>
            {isDragAccept
              ? "ファイルをアップロードします。"
              : isDragReject
                ? "エラー"
                : "ファイルを登録してください。"}
          </p>
          <p>
            {isDragReject
              ? "このファイル形式のアップロードは許可されていません。"
              : "ファイルを選択するか、ドラッグアンドドロップしてください。"}
          </p>
          <button disabled={isDragReject}>ファイルを選択</button>
        </div>
        <p className={styles.note}>
          複数のファイルを選択できます。pdf, png, jpg, jpeg
          ファイルを選択できます。
        </p>
        <p className={styles.caution}>※1ファイルの最大サイズは50MBです</p>
      </div>
      {currentShowFiles && (
        <aside>
          <ul className={styles.file}>
            {currentShowFiles.map((item, index) => (
              <li key={index} className={styles.file_list}>
                {item.isUploaded ? (
                  <div className={styles.file_item}>
                    <div className={styles.file_item_type}>
                      <span className={styles.icon_file}>
                        <File />
                      </span>
                    </div>
                    <div className={styles.file_item_body}>
                      <p className={styles.file_item_name}>{item.file.name}</p>
                    </div>
                    <button
                      type="button"
                      className={styles.file_item_trash}
                      onClick={() => {
                        removeFile(index);
                      }}
                    >
                      <span className={styles.icon_trash}>
                        <Trash />
                      </span>
                    </button>
                  </div>
                ) : (
                  <div className={styles.file_item}>
                    <div className={styles.file_item_type}>
                      <Image
                        src="/../public/spinner.gif"
                        width={20}
                        height={20}
                        alt="loading"
                      />
                    </div>
                    <div className={styles.file_item_body}>
                      <p className={styles.file_item_name}>
                        {item.file.name}をアップロードしています…
                      </p>
                    </div>
                  </div>
                )}
              </li>
            ))}
          </ul>
        </aside>
      )}
    </div>
  );
};

export default FileUploader;

添付したファイルの状態やエラーハンドリングなど欲しい物がだいたい提供されていて直感的に利用できるのでとても使いやすいライブラリですね!

おわりに

アンドパッドではエンジニアの積極採用中です!建築・建設業界の課題解決という、やりがいのあるプロジェクトで働いてみたい方、ぜひお気軽にカジュアル面談などご参加ください! 最後までお読みいただきありがとうございました!

hrmos.co

engineer.andpad.co.jp