react-dropzoneを活用したシンプルなファイルのドラッグ&ドロップと操作デモ

Goodpatchエンジニアの池澤です。この記事はReactのreact-dropzoneを活用したシンプルなファイルドラッグ&ドロップのサンプルについてです。

先日プロジェクトでReact18、Next.js、Typescriptベースでの開発をしていました。そこで以下のような機能を持ったコンポーネントが必要となりました。

  • ローカルファイルをドラッグ&ドロップで読み込める。
  • Reactコンポーネントでファイル読み込みや選択解除をしたい。
  • ブラウザ間の差異吸収や各イベントハンドラの設定等を省略するため、軽量で依存度の少ない形でライブラリを使いたい

こうしたシンプルにファイルのドラッグ&ドロップをサクッと使える実装機能について、以下でライブラリ選定やコード説明、実際に操作できるデモを交えてご紹介して行きたいと思います。

デモ

ぜひCodeSandboxでドラッグ&ドロップを実際に操作してみてください。
対応拡張子:.txt、.csv、.png、.jpg
サーバ送信はしません。フロントエンド範囲のみの動作となります。
テスト用ダミーファイルはこちらをお使いください。

codesandbox.io

ライブラリ候補と選定

Reactのドラッグ&ドロップ系ライブラリは一般的に以下が挙げられます。

  • react-beautiful-dnd
    • 定番のReact向けドラッグ&ドロップライブラリ。JIRAやConfluenceを作っているAtlassian社が開発している。Star数が多い。
  • react-dnd
    • これも古くから利用されている定番のReact向けドラッグ&ドロップライブラリ。ページ内Element同士のドラッグ等もできる高機能性。
  • react-dropzone
    • ローカルファイルのドラッグ&ドロップに特化したライブラリ。リッチな機能はない分構造がシンプル。

これらを比較して見ると、react-beautiful-dndやreact-dndはReact18ではStrictモードだと動作しないバグが続いていたようです(2022年には対応された模様)。 今回は最低限の機能が含まれていて構造がシンプルなreact-dropzoneを使いたいと思います。

react-dropzoneのメリット・デメリット

react-dropzoneを使う前にこのライブラリのメリット・デメリットについても確認です。

メリット

  • ローカルファイルのドラッグ&ドロップのみの機能なので不要な機能や依存性が少ない。
  • シンプルな分、カスタマイズしやすい。
  • 最低限のブラウザ間差異(IE11やEdge等)の対応が含まれている。

デメリット

  • 選択したファイルの解除機能がない。
  • cssのflex-directionでtypescriptエラーになるバグがある。

これらのポイントを活かしつつ、足りない部分は追加実装したりワークアラウンド対応をして補って行きます。

ソースコード説明

今回create-react-appreact-dropzoneをベースとし、react version 18.2.0環境で動作するものを開発します。なお今回の全ソースコードはこちらのGitHubで閲覧できます。

ディレクトリ構成は以下になります。

src
├─ index.tsx
├─ index.css
├─ App.tsx
├─ DragDropZone.tsx
└─ SelectedFileCard.tsx

App.tsx

Reactのメインコンポーネントです。 ここでは <DragDropZone onSelectedFiles={handleSelectedFiles} /> でドラッグ&ドロップ用コンポーネントを配置します。 またファイル変更の度に現在読み込み中のFileを handleSelectedFiles 関数で受け取り、 selectedFiles Stateに格納しています。
「Execute something」ボタンを押すと現在選択中のファイルに対して任意の処理を実行できます。今回はダイアログで選択中ファイル名を表示しています。
(→ GitHub

App.tsx 全コード

import { useState, useCallback } from "react";
import { DragDropZone } from "./DragDropZone";

export default function App() {
  const [selectedFiles, setSelectedFiles] = useState<File[] | undefined>([]);

  const handleSelectedFiles = useCallback(
    (files: File[] | undefined) => {
      setSelectedFiles(files);
    },
    [setSelectedFiles]
  );

  const handleExecute = useCallback(() => {
    if (!selectedFiles) return;
    let fileNameWithLine = "";
    for (const file of selectedFiles) {
      fileNameWithLine += `\n - ${file.name}`;
    }
    alert(`[Selected files] ${fileNameWithLine}`);
  }, [selectedFiles]);

  return (
    <div className="App">
      <h1>React: File drag & drop</h1>
      <p>
        Files can be selected by drag & drop or in a selection dialog.
        <br />
        (sample accept extensions: ".txt", ".csv", ".png", ".jpg")
      </p>

      {selectedFiles && selectedFiles?.length > 0 && (
        <button type="button" onClick={handleExecute}>
          <span>Execute something</span>
        </button>
      )}
      <DragDropZone onSelectedFiles={handleSelectedFiles} />
    </div>
  );
}

DragDropZone.tsx

ドラッグ&ドロップの処理を行うコンポーネントになります。
react-dropzoneは useDropzone 関数を主に使用し、この関数に読み込みファイル受け取り用のハンドラを設定したり対象ファイルの拡張子を制限したりします。
またacceptedFiles、getRootProps、getInputProps、isFocused、isDragAccept、inputRef等のプロパティやフラグを取得します。 これを使ってinputタグに紐づけたり、styleを設定したりします。
(→ GitHub

DragDropZone.tsx 全コード

import { memo, useState, useCallback, useMemo } from "react";
import { useDropzone } from "react-dropzone";
import { SelectedFileCard } from "./SelectedFileCard";

export const DragDropZone = memo(
  ({
    onSelectedFiles
  }: {
    onSelectedFiles: (files: File[] | undefined) => void;
  }) => {
    const [currentFiles, setCurrentFiles] = useState<File[]>([]);

    const onDropAccepted = useCallback(
      (files: File[]) => {
        const mixFiles = [...files, ...currentFiles];
        const uniqueFiles = Array.from(
          new Map(mixFiles.map((file) => [file.name, file])).values()
        );
        setCurrentFiles(uniqueFiles);
        onSelectedFiles(uniqueFiles);
      },
      [currentFiles, setCurrentFiles, onSelectedFiles]
    );

    const {
      acceptedFiles,
      getRootProps,
      getInputProps,
      isFocused,
      isDragAccept,
      inputRef
    } = useDropzone({
      accept: {
        "text/csv": [".csv", ".CSV"],
        "text/plain": [".txt", ".TXT", ".text", ".TEXT"],
        "image/jpeg": [".jpg", ".JPG", ".jpeg", ".JPEG"],
        "image/png": [".png", ".PNG"]
      },
      onDropAccepted
    });

    const baseStyle = useMemo(
      () => ({
        flex: 1,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        minWidth: "200px",
        height: "100%",
        padding: "20px",
        borderWidth: "2px",
        borderRadius: "8px",
        borderColor: "#bbbbbb",
        borderStyle: "dashed",
        backgroundColor: "#fafafa",
        color: "#888888",
        outline: "none"
      }),
      []
    );

    const focusedStyle = useMemo(
      () => ({
        borderColor: "#44AA55"
      }),
      []
    );

    const acceptStyle = useMemo(
      () => ({
        borderColor: "#44AA55",
        backgroundColor: "#fafafa",
        color: "#44AA55"
      }),
      []
    );

    const style = useMemo(
      () =>
        ({
          ...baseStyle,
          ...(isFocused ? focusedStyle : {}),
          ...(isDragAccept ? acceptStyle : {})
          // Workaround of react-dropzone: flexDirection is not assignable to CSSProperties
          // Reference: https://github.com/cssinjs/jss/issues/1344
        } as React.CSSProperties),

      [isFocused, isDragAccept, baseStyle, focusedStyle, acceptStyle]
    );

    const clearFile = useCallback(() => {
      if (inputRef?.current?.value) {
        inputRef.current.value = "";
      }
      setCurrentFiles([]);
      onSelectedFiles(undefined);
    }, [inputRef, onSelectedFiles]);

    // Workaround of react-dropzone: Make it possible to remove file from acceptedFiles state
    // Reference: https://github.com/react-dropzone/react-dropzone/issues/805
    const handleRemoveFile = useCallback(
      (file: File) => {
        const newFiles = [...currentFiles];
        newFiles.splice(newFiles.indexOf(file), 1);
        setCurrentFiles(newFiles);
        onSelectedFiles(newFiles);
        acceptedFiles.splice(acceptedFiles.indexOf(file), 1);
        if (newFiles.length <= 0) {
          clearFile();
        }
      },
      [currentFiles, setCurrentFiles, clearFile, acceptedFiles, onSelectedFiles]
    );

    const handleRemoveAll = useCallback(() => {
      clearFile();
      acceptedFiles.length = 0;
      acceptedFiles.splice(0, acceptedFiles.length);
    }, [clearFile, acceptedFiles]);

    const acceptingFilesCard = useMemo(() => {
      return currentFiles.map((file, i) => (
        <SelectedFileCard
          key={i}
          name={file.name ?? ""}
          fileSize={file.size}
          onRemove={() => {
            handleRemoveFile(file);
          }}
        />
      ));
    }, [currentFiles, handleRemoveFile]);

    return (
      <>
        <div className="dragdrop-hitarea-wrap">
          <div {...getRootProps({ style })}>
            <input {...getInputProps()} />
            <span className="icon-upload">⇪</span>
            <p>Drag & Drop / Select file from dialog</p>
          </div>
        </div>

        {currentFiles && currentFiles.length > 0 && (
          <>
            <div className="button-remove-all-wrap">
              <button type="button" onClick={handleRemoveAll}>
                <span>Remove all</span>
              </button>
            </div>
            <div className="accepting-file-card-list">{acceptingFilesCard}</div>
          </>
        )}
      </>
    );
  }
);
DragDropZone.displayName = "DragDropZone";

ワークアラウンド1:flexDirectionが使えない

react-dropzoneで要素にスタイルを定義するには <div {...getRootProps({ style })}> のようにgetRootProps関数にstyleオブジェクトを渡します。
このstyleオブジェクトで flexDirection: "column" のように定義してもflexDirectionはCSSProperties型定義にないというtypescriptのエラーが発生します。(厳密にはcssinjs/jssライブラリ側の問題です)
typescriptエラー内容:

[{
    "owner": "typescript",
    "code": "2322",
    "message": "型 '{ flexDirection: string; }' を型 'Properties<string | number, string & {}>' に割り当てることはできません。\n  プロパティ 'flexDirection' の型に互換性がありません。\n    型 'string' を型 'FlexDirection | undefined' に割り当てることはできません。",
    "source": "ts",
}]

対策として {style定義} as React.CSSProperties のようにReact.CSSPropertiesで型変換することで使えるようになります。

const style = useMemo(
  () =>
    ({
      ...baseStyle,
      ...(isFocused ? focusedStyle : {}),
      ...(isDragAccept ? acceptStyle : {})
      // Workaround of react-dropzone: flexDirection is not assignable to CSSProperties
      // Reference: https://github.com/cssinjs/jss/issues/1344
    } as React.CSSProperties),  // React.CSSPropertiesで型変換する

  [isFocused, isDragAccept, baseStyle, focusedStyle, acceptStyle]
);

ISSUE: flexDirection is not assignable to CSSProperties

ワークアラウンド2:選択したファイルの解除機能がない

react-dropzoneにはそもそも選択解除機能がありません。ですが実際に使うには選択中ファイルの個別解除や全解除が必要なケースが多いと思います。
対策として、選択されたファイルを別途stateに保持するようにし、このstate側で表示対象を抽出することで選択解除が行なえます。またreact-dropzoneは acceptedFiles 配列にファイルを保持して管理します。解除する際はこの acceptedFiles 配列内の要素も削除が必要になります。

// 選択ファイルの保持
const onDropAccepted = useCallback(
  (files: File[]) => {
    const mixFiles = [...files, ...currentFiles];
    const uniqueFiles = Array.from(
      new Map(mixFiles.map((file) => [file.name, file])).values()
    );
    setCurrentFiles(uniqueFiles);  // 選択したファイルを自作stateに保持する
    onSelectedFiles(uniqueFiles);
  },
  [currentFiles, setCurrentFiles, onSelectedFiles]
);
// 解除の際はacceptedFiles配列の対象要素も削除する
const handleRemoveFile = useCallback(
  (file: File) => {
    const newFiles = [...currentFiles];
    newFiles.splice(newFiles.indexOf(file), 1);
    setCurrentFiles(newFiles);
    onSelectedFiles(newFiles);
    acceptedFiles.splice(acceptedFiles.indexOf(file), 1);  // acceptedFiles内の対象要素も削除する
    if (newFiles.length <= 0) {
      clearFile();
    }
  },
  [currentFiles, setCurrentFiles, clearFile, acceptedFiles, onSelectedFiles]
);

ISSUE: Make it possible to remove file from acceptedFiles state

SelectedFileCard.tsx

読み込んだファイルの名前と容量をカードとして表示するコンポーネントです。ここではファイル容量をキロバイトに変換して表示しています。
(→ GitHub

SelectedFileCard.tsx 全コード

import { memo, useMemo } from "react";

export const SelectedFileCard = memo(
  ({
    name = "",
    fileSize = 0,
    onRemove
  }: {
    name: string;
    fileSize: number;
    onRemove: () => void;
  }) => {
    const formatByteToKBText = (value: number): string => {
      const calcValue = value / 1024;
      const roundedCalcValue = calcValue.toFixed(1);
      return `${roundedCalcValue}KB`;
    };

    const fileSizeKB = useMemo(() => {
      return formatByteToKBText(fileSize);
    }, [fileSize]);

    return (
      <div className="accepting-file-card">
        <div className="accepting-file-card-texts">
          <p>・</p>
          <p>{name}</p>
          <p>/</p>
          <p>{fileSizeKB}</p>
        </div>
        <button
          className="button-accepting-file-card-remove"
          type="button"
          onClick={onRemove}
        >
          <span>✕</span>
        </button>
      </div>
    );
  }
);
SelectedFileCard.displayName = "SelectedFileCard";

index.tsx index.css

create-react-appで生成したシンプルなindex.tsxです。
cssは各wrapperや選択中カード、ボタン等の軽微なスタイルをプレーンなcssで作成しています。
(→ GitHub

index.tsx 全コード

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

index.css 全コード

body {
  margin: 0;
}

.App {
  font-family: sans-serif;
  text-align: center;
}

.dragdrop {
  background-color: "#ff0000";
}

.dragdrop-hitarea-wrap {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  margin: 20px 0;
}

.icon-upload {
  font-size: 24px;
}

.button-remove-all-wrap {
  display: flex;
  justify-content: flex-end;
  padding: 0 20px 8px 0;
}

.accepting-file-card-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 0 20px 0;
}

.accepting-file-card {
  display: flex;
  min-width: 200px;
  min-height: 20px;
  flex-wrap: nowrap;
  text-align: left;
  word-break: break-all;
  align-items: center;
  padding: 0 12px 0 12px;
  justify-content: space-between;
  gap: 16px;
  background-color: #fafafa;
  border: 1px solid #3366cc;
  border-radius: 12px;
}

.accepting-file-card-texts {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 16px;
  line-height: 1rem;
  color: #222222;
}

.button-accepting-file-card-remove {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border: 1px solid #999999;
  color: #666666;
  border-radius: 50%;
}

まとめ

Reactでサクッと簡単にファイルをドラッグ&ドロップできるものが欲しくて始めましたが、中々丁度よいものがなかったので今回開発をしてみました。 既存ライブラリも例えばReactがversion18になったら使えなくなるケースもあると学べましたし、丁度よい粒度のライブラリ選びやカスタマイズできる余地があることが改めて大事だなと思いました。

今回開発したデモやソースはGitHubやCodeSandboxにアップしてありますので、何かの機会にお使いいただければ嬉しいです。