【React / Next】ドラッグ&ドロップで画像を複数選択してDBにアップロードする

個人の備忘録です。

# 環境
- react: 18.2.0
- next: 12.2.3
- react-dropzone: 14.2.2
- @chakra-ui/react: 2.2.4

# やりたいこと
- 複数の画像ファイルをページにドラッグ&ドロップする
- 選択された画像をプレビュー表示する
- プレビュー画像は削除可能
- 選択された画像を DB へアップロードする
- ライブラリは `react-dropzone` を使用

# 完成イメージ
![Untitled.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/399610/bb5c7208-7792-5d80-d40d-07b70084dea4.gif)

# install
- まずは、`react-dropzone` を install

```
yarn add react-dropzone
```

# サンプルコード
```tsx:Contents.tsx
import { Img } from '@chakra-ui/react';
import { useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { useImageNew } from 'usecase/useImageNew';
import styles from './contents.module.scss';

export const Contents = () => {
  // ブラウザ表示用の paths
  const [previewImagePaths, setPreviewImagePaths] = useState<string[]>();
  // upload用の files
  const [files, setFiles] = useState<File[]>();

  const { imageNewHandler } = useImageNew();

  //*********************
  /** 選択された画像を処理 */
  //*********************
  const onDrop = (acceptedFiles: File[]) => {
    // 引数で受け取れる値は、File型の配列なので upload用のstateへsetする
    setFiles(acceptedFiles);
    // ブラウザで画像を表示させるための、一時的なURLをメモリに生成する
    // @see https://developer.mozilla.org/ja/docs/Web/API/URL/createObjectURL
    const dataUrls = acceptedFiles.map((file) => URL.createObjectURL(file));
    // createObjectURLで生成された、ブラウザ表示用のURLをstateへsetする
    setPreviewImagePaths(dataUrls);
  };
  const { getRootProps, getInputProps } = useDropzone({ onDrop });

  //************************************************
  /** 削除ボタンをクリックすると、プレビュー画像を削除する */
  //************************************************
  const handleClickDelete = () => {
    setPreviewImagePaths([]);
  };

  //*********************************
  /** アップロードボタンクリック時の処理 */
  //*********************************
  const handleClickUpload = async () => {
    const formData = buildFormData(files);
    const response = await imageNewHandler(formData);
    // 省略
  };

  const buildFormData = (files?: File[]) => {
    if (!files) {
      return new FormData();
    }
    // DB へアップロードするために、FormData へ append する
    // @see https://developer.mozilla.org/ja/docs/Web/API/FormData/Using_FormData_Objects
    const formData = new FormData();
    // append の第一引数はバックエンドと合わせる
    files.forEach((file) => formData.append('images', file, file.name));
    return formData;
  };

  return (
    <>
      <div className={styles.contents}>
        <div {...getRootProps({ className: `dropzone ${styles.contents__input}` })}>
          <input {...getInputProps()} />
          <p>
            Drag and drop some image files here, or click to select image files
          </p>
        </div>
      </div>
      {previewImagePaths &&
        previewImagePaths.map((image, i) => (
          <div key={i}>
            <Img src={image} />
          </div>
        ))}
      <div className={styles.contents__button}>
        <button onClick={handleClickDelete}>Delete preview images</button>
      </div>
      <div className={styles.contents__button}>
        <button onClick={handleClickUpload}>Upload images</button>
      </div>
    </>
  );
};
```

```scss:contents.module.scss
.contents {
  margin: 0 auto;
  border: 2px dashed #ccc;
  background-color: #eee;
  color: #bbb;
  max-width: 600px;
  height: 100px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;

  &__input {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  &__button {
    margin-top: 20px;
  }
}
```

```tsx:useImageNew.ts
import { ImagePostResponse } from 'entity/dropzone/imagePost';
import baseHttpClient from 'infrastructure/httpClient';

// NOTE: 簡易的なレイヤー
export const useImageNew = () => {
  const imageNewHandler = async (formData: FormData) => {
    const response = await baseHttpClient.post<ImagePostResponse>(
      `/api/image`,
      formData,
      {
       // content-type を multipart/form-data に指定
       // @see https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Type
       // @see https://developer.mozilla.org/ja/docs/Web/API/FormData/Using_FormData_Objects
        headers: {
          'content-type': 'multipart/form-data',
        },
      },
    );

    return response?.data;
  };

  return {
    imageNewHandler,
  };
};
```

# モーダルパターン
- モーダルを開いてから、ドラッグ&ドロップするパターン

# 完成イメージ
![Untitled2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/399610/d371be46-b847-205c-eca2-3669c4c1d1a5.gif)

# サンプルコード
```tsx:contents.tsx
import { Img, useDisclosure } from '@chakra-ui/react';
import { useState } from 'react';
import { useDropzone } from 'react-dropzone';
import { useImageNew } from 'usecase/useImageNew';
import { ImageModal } from '../modal/modal';
import styles from './contents.module.scss';

export const Contents = () => {
  const { isOpen, onOpen, onClose } = useDisclosure();
  // ブラウザ表示用の paths
  const [previewImagePaths, setPreviewImagePaths] = useState<string[]>();
  // upload用の files
  const [files, setFiles] = useState<File[]>();

  const { imageNewHandler } = useImageNew();

  //*********************
  /** 選択された画像を処理 */
  //*********************
  const selectImages = (acceptedFiles: File[]) => {
    setFiles(acceptedFiles);
    const dataUrls = acceptedFiles.map((file) => URL.createObjectURL(file));
    setPreviewImagePaths(dataUrls);
  };

  const handleClickDelete = () => {
    setPreviewImagePaths([]);
  };

  const handleClickUpload = async () => {
    const formData = buildFormData(files);
    const response = await imageNewHandler(formData);
  };

  const buildFormData = (files?: File[]) => {
    if (!files) {
      return new FormData();
    }
    // DB へアップロードするために、FormData へ append する
    const formData = new FormData();
    // append の第一引数はバックエンドと合わせる
    files.forEach((file) => formData.append('images', file, file.name));
    return formData;
  };

  const handleClickModal = () => {
    onOpen();
  };

  return (
    <div className={styles.contents}>
      {previewImagePaths &&
        previewImagePaths.map((image, i) => (
          <div key={i}>
            <Img src={image} />
          </div>
        ))}
      <ImageModal
        isOpen={isOpen}
        onClose={onClose}
        selectImages={selectImages}
      />
      <div className={styles.contents__button}>
        <button onClick={handleClickDelete}>Delete preview images</button>
      </div>
      <div className={styles.contents__button}>
        <button onClick={handleClickUpload}>Upload images</button>
      </div>
      <div className={styles.contents__button}>
        <button onClick={handleClickModal}>Open modal</button>
      </div>
    </div>
  );
};
```

```tsx:modal.tsx
import {
  Box,
  Modal,
  ModalBody,
  ModalContent,
  ModalFooter,
  ModalHeader,
  ModalOverlay,
} from '@chakra-ui/react';
import { useDropzone } from 'react-dropzone';
import styles from './modal.module.scss';

type Props = {
  isOpen: boolean;
  onClose: () => void;
  selectImages: (acceptedFiles: File[]) => void;
};

export const ImageModal = ({ isOpen, onClose, selectImages }: Props) => {
  //************************************
  /** 選択された画像を処理するイベントを通知 */
  //************************************
  const onDrop = (acceptedFiles: File[]) => {
    selectImages(acceptedFiles);
  };
  const { getRootProps, getInputProps } = useDropzone({ onDrop });

  const handleClickOk = () => {
    onClose();
  };

  return (
    <Modal isOpen={isOpen} onClose={onClose} isCentered size={'2xl'}>
      <ModalOverlay />
      <ModalContent>
        <ModalHeader className={styles.modal__header}>画像の選択</ModalHeader>
        <ModalBody className={styles.modal__body}>
          <div className={styles.modal__contents}>
            <div {...getRootProps({ className: `dropzone ${styles.modal__input}` })}>
              <input {...getInputProps()} />
              <p>
                Drag and drop some image files here, or click to select image
                files
              </p>
            </div>
          </div>
        </ModalBody>
        <ModalFooter>
          <Box className={styles.modal__footer}>
            <button onClick={handleClickOk}>OK</button>
          </Box>
        </ModalFooter>
      </ModalContent>
    </Modal>
  );
};
```

# 参考
## react-dropzoneについて

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

https://apprit.jp/react-dropzone/

## multipart/form-data について
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Type

https://developer.mozilla.org/ja/docs/Web/API/FormData/Using_FormData_Objects

## FormData について
https://developer.mozilla.org/ja/docs/Web/API/FormData/Using_FormData_Objects

## FileReader について
https://developer.mozilla.org/ja/docs/Web/API/FileReader

## File / Blob について
https://developer.mozilla.org/ja/docs/Web/API/File

https://developer.mozilla.org/ja/docs/Web/API/Blob

## Base64 について
https://wa3.i-3-i.info/word11338.html

## それぞれの特徴について
https://qiita.com/allein-s/items/13d0e2fc0896df83b234