React Hook Formで画像アップロードを実装する

# 概要
[React Hook Form](https://react-hook-form.com/jp)というライブラリを使って、Reactでアンケートフォームを実装しました。
その中で画像アップロードを実装した際に、様々な工夫を行ったのでその記録を残します。

## イメージ
![画面収録 2020-09-30 23.40.43.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146971/a03c90f7-3966-120f-bf3f-50baa3ff6f47.gif)

# 要求仕様
1. アップロードできる画像サイズの合計は10MB以内
2. アップロードする画像のプレビューが必要
3. アップロードできる画像（＝プレビューの画像）の枚数は3枚
4. 同じ画像はアップロードしないように弾く
5. 画像のみアップロードする（jpeg, png, bmp, gif, svgのみ）
6. それぞれの条件に反した場合には、エラーを表示する

## React Hook Formを活用した背景
1. 画像アップロード以外に、入力が必要な項目が存在したため
2. ReactにおいてFormの実装がやりやすく、パフォーマンスも高いため

# 手順
1. 画像アップロード部分のコンポーネント（`PhotosUpload.tsx`）の作成
2. フォームを表示するページ（`Questionnaire.tsx`）の作成

# 工夫点
1. アップロードできる画像サイズの合計は10MB以内
**→画像を圧縮することで、サイズを気にすることなくアップロードできるようにしました**
　1枚あたり、3MBに圧縮する処理を[browser-image-compression](https://www.npmjs.com/package/browser-image-compression)を用いて行いました。

2. アップロードする画像のプレビューが必要
**→画像アップロードのコンポーネントで、選択された画像をstateの配列で管理し、その配列によってDOMを出し分けました**

3. アップロードできる画像（＝プレビューの画像）の枚数は3枚
**→上記stateの配列のlengthを3以下に制限することで、アップロードできる（プレビューできる）画像を3枚に制限しました**

4. 同じ画像はアップロードしないように弾く
**→同じサイズの画像は配列に追加できないというロジックで実装しました**
　最初は画像の名前で弾く実装を行っていたのですが、safariで予想通りの挙動を示さなかったため変更しました。
　safariではheifの画像を選択した場合、jpegに変換する処理が走るようで、その際に画像名が自動的に付けられてしまうためであることが分かりました。
より厳密に行うには、lastModifiedなど追加の情報を使うか、exifを読み込んで別の情報を取得することで、固有性を正確に担保できます。

5. 画像のみアップロードする（jpeg, png, bmp, gif, svgのみ）
**→多くのブラウザではinputタグのacceptプロパティで`"image/*"`を指定すればいいですが、より厳密に行うために上記の配列への追加の際にfileのtypeプロパティで対象外のファイルを弾きました**
　多くのスマートフォンのブラウザでは、rawやheifなどが`"image/*"`の対象となります。ただ一方で、imgタグではこれらの画像を表示することができません。そのため、今回これらの画像は対象外とし、配列の追加の際に弾く実装を行いました。
　ただ前述の通り、safariではheifはjpegに変換されるため利用できます。（Androidではheifは選択できるものの、変換されないため、今回の場合は対象外になります。）
ちなみにPCのブラウザではrawやheifなどは選択の際に無効になります。

6. それぞれの条件に反した場合には、エラーを表示する
　上記、①３枚以内か、②同じ画像ではないか、③対象のファイルタイプかの3点それぞれについて、エラーを保持するstateを準備し、それぞれのエラーが発生した際にtrueとすることで、それをきっかけにエラーのDOMを出し分けました。

# 実装
```tsx:PhotosUpload.tsx
import React, { useState } from "react";
import * as styles from "./style.module.sass";
import PhotoSample from "../PhotoSample";

interface PhotosUploadProps {
  name: string;
  componentRef?: (instance: HTMLInputElement | null) => void;
  photos: File[];
  setPhotos: (files: File[]) => void;
}

const PhotosUpload: React.FC<PhotosUploadProps> = ({
  name,
  componentRef,
  photos,
  setPhotos,
}: PhotosUploadProps): React.ReactElement => {
  const [isSameError, setIsSameError] = useState(false);
  const [isNumberError, setIsNumberError] = useState(false);
  const [isFileTypeError, setIsFileTypeError] = useState(false);

  const resetErrors = () => {
    setIsSameError(false);
    setIsNumberError(false);
    setIsFileTypeError(false);
  };

  const handleFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
    if (event.target.files === null || event.target.files.length === 0) {
      return;
    }
    const files = Object.values(event.target.files).concat();
    // 初期化することで同じファイルを連続で選択してもonChagngeが発動するように設定し、画像をキャンセルしてすぐに同じ画像を選ぶ動作に対応
    event.target.value = "";
    resetErrors();

    const pickedPhotos = files.filter((file) => {
      if (
        ![
          "image/gif",
          "image/jpeg",
          "image/png",
          "image/bmp",
          "image/svg+xml",
        ].includes(file.type)
      ) {
        setIsFileTypeError(true);
        return false;
      }

      const existsSameSize = photos.some((photo) => photo.size === file.size);
      if (existsSameSize) {
        setIsSameError(true);
        return false;
      }

      return true;
    });

    if (pickedPhotos.length === 0) {
      return;
    }
    const concatPhotos = photos.concat(pickedPhotos);
    if (concatPhotos.length >= 4) {
      setIsNumberError(true);
    }
    setPhotos(concatPhotos.slice(0, 3));
  };

  const handleCancel = (photoIndex: number) => {
    if (confirm("選択した画像を消してよろしいですか？")) {
      resetErrors();
      const modifyPhotos = photos.concat();
      modifyPhotos.splice(photoIndex, 1);
      setPhotos(modifyPhotos);
    }
  };

  return (
    <>
      <div className={styles.topContainer}>
        {[...Array(3)].map((_: number, index: number) =>
          index < photos.length ? (
            <button
              type="button"
              className={styles.imageContainer}
              key={index}
              onClick={() => handleCancel(index)}
            >
              <img
                className={styles.image}
                src={URL.createObjectURL(photos[index])}
                alt={`あなたの写真 ${index + 1}`}
              />
            </button>
          ) : (
            <label htmlFor={name} key={index}>
              <PhotoSample number={index + 1} />
            </label>
          )
        )}
      </div>
      {isSameError && (
        <p>※既に選択された画像と同じものは表示されません</p>
      )}
      {isNumberError && (
        <p>※3枚を超えて選択された画像は表示されません</p>
      )}
      {isFileTypeError && (
        <p>※jpeg, png, bmp, gif, svg以外のファイル形式は表示されません</p>
      )}

      <div className={styles.bottomContainer}>
        <div>
          <p className={styles.note}>※最大3枚まで</p>
        </div>
        <label className={styles.label} htmlFor={name}>
          <div className={styles.plus}></div>
          写真を追加
          <input
            className={styles.input}
            type="file"
            name={name}
            id={name}
            ref={componentRef}
            accept="image/*"
            onChange={handleFile}
            multiple
          />
        </label>
      </div>
    </>
  );
};

export default PhotosUpload;
```


```tsx:Questionnaire.tsx
import { useForm } from "react-hook-form";
import { navigate } from "gatsby";
import axios from "axios";
import imageCompression from "browser-image-compression";
import PhotosUpload from "../PhotosUpload";

type Inputs = {
  email: string;
  phone: string;
};

const Questionnaire: React.FC= () => {
  const { register, errors, handleSubmit } = useForm<Inputs>({
    mode: "onBlur",
  });
  const [photos, setPhotos] = useState<File[]>([]);

  const onSubmit = async (data: Inputs): Promise<void> => {
    const { email, phone } = data;
    if (
      email === "" &&
      phone === "" &&
      photos.length === 0
    ) {
      // アンケートフォームが空の場合はPOSTしない
      return;
    }

    // 画像を送信できるようにFormDataに変換する
    const formData = new FormData();
    formData.append("email", email);
    formData.append("phone", phone);

    const compressOptions = {
      // 3MB以下に圧縮する
      maxSizeMB: 3,
    };
    const compressedPhotoData = await Promise.all(
      photos.map(async (photo) => {
        return {
          blob: await imageCompression(photo, compressOptions),
          name: photo.name,
        };
      })
    );
    compressedPhotoData.forEach((photoData) => {
      formData.append("photo", photoData.blob, photoData.name);
    });

    axios({
      url: "/api/register",
      method: "post",
      data: formData,
      headers: {
        "content-type": "multipart/form-data",
      },
    })
      .then(() =>  navigate("/complete");)
      .catch((error) => {
        alert("エラーが発生しました。");
      });
  };

  return (
      <form onSubmit={handleSubmit(onSubmit)}>
          <div className={styles.dataContainer}>
            <input
              name="email"
              ref={register({required : true })}
              error={errors.email !== undefined}
            />
            <input
              name="phone"
              ref={register({required : true })}
              error={errors.phone !== undefined}
            />
          </div>
          <div className={styles.photoUpload}>
            <PhotosUpload name="photos" photos={photos} setPhotos={setPhotos} />
          </div>
          <div className={styles.button}>
            <button　disabled={ />
          </div>
      </form>
  );
};
export default Questionnaire;
```


# 参考
- https://medium.com/@ibamibrhm/custom-upload-button-image-preview-and-image-upload-with-react-hooks-a7977618ee8c