フロントエンド（React）でのファイルの扱い方を会得したい

# はじめに
最近はフロントエンドを触ることが多いのですが、今までファイルを扱う機能の実装をしたことがなく、しかも担当しているシステムは画像ファイルやcsvファイルを扱う箇所があり、そこのコードが理解できず...
ということで頑張って勉強してみました。


# ファイルの扱い方の基本
## ファイルの選択

`input`に`type="file`を指定することで、ユーザーにファイルを選択させることができます。

```html
<input name="file1" type="file" />
<input name="file2" type="file" />
<input name="files" type="file" multiple />
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3640003/42bec3e0-4792-33f6-9426-55e55e8bdf19.png)


`accept`を指定することで、選択できるファイルを制限することもできます。

```html
<input name="file1" type="file" accept="image/jpeg" />
<input name="file2" type="file" accept="image/jpeg, image/png" />
<input name="files" type="file" accept="image/*" multiple />
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3640003/6fb41631-e05a-4e34-273c-820533553cc0.png)

## ファイルの情報へのアクセス

選択されたファイルの情報には、`files`でアクセスできます。
（例：`e.target.files`）

`files`には`FileList`という形式でデータが保持されています。

`FileList`のままだと扱いにくいので、`File`の部分を取り出します。
今回は、取り出した`File`（単一・複数）を`useState`のそれぞれ`file`, `files`に格納しています。
型は`File`、および`File`の配列です。

取り出したものについては、`name`でファイル名、`size`でサイズ（バイト）を確認することができます。

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

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

```tsx
import React, { useState } from "react";

export const FileInputs = () => {
  const [file, setFile] = useState<File | undefined>(undefined);
  const [files, setFiles] = useState<File[]>([]);
    
  console.log(file?.name);
  // sample_image_1.jpg
  console.log(file?.size);
  // 2769524

  // 単一ファイルの場合
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files.length > 0) {
      setFile(e.target.files[0]);
    } else {
      setFile(undefined);
    }
  };

  // 複数ファイルの場合
  const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(Array.from(e.target.files));
    } else {
      setFiles([]);
    }
  };

  return (
    <>
      <input onChange={handleFileChange} name="file" type="file" />
      <input onChange={handleFilesChange} name="files" type="file" multiple />
    </>
  );
};
```

## ドラッグ&ドロップでファイル選択

ドラッグ&ドロップでファイル選択させる場合、`handleDrop`の`dataTransfer`から`files`にアクセスできます。
なお、ブラウザのデフォルトの動作を防ぐために、`handleDrop`と`handleDragOver`に`preventDefault()`を設定しています。

https://developer.mozilla.org/ja/docs/Web/API/File_API/Using_files_from_web_applications#%E3%83%89%E3%83%A9%E3%83%83%E3%82%B0_%E3%83%89%E3%83%AD%E3%83%83%E3%83%97%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%9F%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E9%81%B8%E6%8A%9E

```tsx
import React, { useState } from "react";

export const FileInputs = () => {
  const [files, setFiles] = useState<File[]>([]);

  files.forEach((file) => {
    console.log(file.name);
    // sample_image_1.jpg
    console.log(file.size);
    // 2769524
  });

  const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
  };

  const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    if (e.dataTransfer.files) {
      setFiles(Array.from(e.dataTransfer.files));
    }
  };

  return (
    <>
      <div onDragOver={handleDragOver} onDrop={handleDrop} style={{border: "1px dashed black", height: "5em"}}>
        ここにファイルをドラッグ＆ドロップしてください
      </div>
    </>
  );
};
```


# 画像ファイルの扱い
## 画像のプレビュー表示

次に、ユーザーが選択した画像ファイルのプレビュー機能を作ってみます。
大まかな流れは以下の通りです。

1. ユーザーが画像ファイルを選択し、画像ファイルの情報が`files`（`File`オブジェクトの配列）に格納
2. 画像ファイルからオブジェクトURLを生成する[^1]
3. オブジェクトURLを`img`要素の`src`属性に渡す

オブジェクトURLとは、`File`オブジェクトや`Blob`オブジェクトで指定されたファイルなどを参照するためのURLらしいです。

[^1]:オブジェクトURLではなくデータURLを使用する方法もありますが、生成AIいわくURLの生成はオブジェクトURLのほうが早いらしいので、オブジェクトURLを採用しています。

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

https://developer.mozilla.org/ja/docs/Web/API/File_API/Using_files_from_web_applications#%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88_url_%E3%82%92%E5%88%A9%E7%94%A8%E3%81%99%E3%82%8B

なお、ファイルが同じでも`URL.createObjectURL()`が実行されるたび、異なるオブジェクトURLが生成されます。また、不要になったオブジェクトURLは、`URL.revokeObjectURL()`でメモリから解放しています。



```tsx
import React, { useState, useEffect } from "react";

export const FileInputs = () => {
  const [files, setFiles] = useState<File[]>([]);

  const objectUrls = files.map(file => URL.createObjectURL(file));

  useEffect(() => {
    // objectUrls変更時とアンマウント時にオブジェクトURLを解放する
    return () => {
      objectUrls.forEach(url => URL.revokeObjectURL(url));
    };
  }, [objectUrls]);

  const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(Array.from(e.target.files));
    } else {
      setFiles([]);
    }
  };

  return (
    <>
      <input onChange={handleFilesChange} name="files" type="file" accept="image/*" multiple />
      <div>
        {objectUrls.map((url, index) => (
          <img key={index} src={url} style={{ maxWidth: '300px', height: 'auto', margin: '10px' }} alt={`Preview ${index}`} />
        ))}
      </div>
    </>
  );
};
```

このように、「ファイル選択」で選択した画像が表示されるようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3640003/95ca5f28-ce1b-267f-94b9-f8c36d2379a9.png)

## 画像の幅・高さを取得

続いて、あまり需要はないかもしれませんが、選択した画像の幅、高さを取得してみようと思います。

1. ユーザーが画像ファイルを選択し、画像ファイルの情報が`files`（`File`オブジェクトの配列）に格納
2. `new Image()`でインスタンスを作成
3. 画像の読み込みに成功した場合と、失敗した場合の処理を設定
4. `src`に画像のURLを指定

画像の読み込みに成功すると、画像の高さや幅に関するプロパティにアクセスできます。
今回は`img.naturalWidth`, `img.naturalHeight`で元の画像の高さと幅を取得しています。

（生成AIいわく、ハンドラ設定の前にURLを設定した場合、ハンドラの設定前に画像読み込みが完了しハンドラが実行されない可能性があるだとか）

https://developer.mozilla.org/ja/docs/Web/API/HTMLImageElement/Image

```tsx
import React, { useState } from "react";

export const FileInputs = () => {
  const [files, setFiles] = useState<File[]>([]);
  const objectUrls = files.map(file => URL.createObjectURL(file));

  // 画像を1つずつ読み込む
  objectUrls.forEach((url) => {
    const img = new Image();
    img.onload = () => {
      console.log(`画像の幅: ${img.naturalWidth}, 画像の高さ: ${img.naturalHeight}`);
      // 画像の幅: 4284, 画像の高さ: 5712
    };
    img.onerror = () => {
      console.error("画像の読み込みに失敗しました");
    };
    img.src = url;
  });
  
  const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    objectUrls.forEach((url) => URL.revokeObjectURL(url));
    if (e.target.files) {
      setFiles(Array.from(e.target.files));
    } else {
      setFiles([]);
    }
  };

  return (
    <>
      <input onChange={handleFilesChange} name="files" type="file" accept="image/*" multiple />
    </>
  );
};
```


# cvsファイルの扱い
## csvファイルのデータの取得

ここからは、csvファイルの扱いについて解説します。

まずは、csvファイルのデータを取得してみます。

1. ユーザーがcsvファイルを選択し、csvファイルの情報が`file`（`File`オブジェクト）に格納
2. `FileReader()`でインスタンスを作成
3. ファイルの読み込みに成功した場合と、失敗した場合の処理を設定
ファイルの読み込みに成功すると、`result`にデータが格納される
4. ファイルの読み込み方法を指定（`readAsText(file)`）

今回はcsvファイル（カンマと改行で区切られているテキストデータ）を読み込むので、`readAsText(file)`でcsvファイルを読み込みます。
読み込みが成功すると、`result`に文字列が格納されます。

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

```tsx
import React, { useState } from "react";

export const FileInputs = () => {
  const [file, setFile] = useState<File | undefined>(undefined);

  if (file && file.type === "text/csv") {
    const reader = new FileReader();
    reader.onload = (event) => {
      const csvString = event.target?.result;
      console.log(csvString);
    };
    reader.onerror = () => {
      console.error("failed to read file");
    };
    reader.readAsText(file);
  }
  
  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files.length > 0) {
      setFile(e.target.files[0]);
    } else {
      setFile(undefined);
    }
  };

  return (
    <>
      <input onChange={handleFileChange} name="file" type="file" accept=".csv" />
    </>
  );
};
```
試しに、以下のようなcsvファイルを選択すると、

| id | name |
|:---|:-----|
| 1 | foo |
| 2 | bar |
| 3 | baz |

コンソールでは、以下のように表示されます。

```text:コンソール
id,name
1,foo
2,bar
3,baz
```

## csvファイルの作成、ダウンロード

最後に、選択したcsvファイルに

| 4 | qux |
|:---|:---|

という行を追加し、そのcsvファイルをダウンロードする機能を作ってみます。

大まかな流れは以下の通りです。
1. ユーザーがcsvファイルを選択し、csvファイルの情報が`file`（`File`オブジェクト）に格納
2. ダウンロードボタンが押される
3. `file`をもとに、選択されたcsvファイルを読み込み、取得した文字列を`csvString`に格納
`csvString`の中身は、例えば `1,foo\n2,bar\n3,baz`
4. `4`と`qux`を追加した、新しいcsv用の文字列を生成し、`newCsvString`に格納
`newCsvString`の中身は、`1,foo\n2,bar\n3,baz\n4,qux\n`
5. `newCsvString`から新しいcsvファイルを作成
6. 新しいcsvファイルをダウンロード

```tsx
import React, { useState } from "react";
import { 
  convertCsvToString,
  addCsvStringRow,
  convertStringToCsv,
  downloadFile 
} from "csv関連の関数";

export const FileInputs = () => {
  const [file, setFile] = useState<File | undefined>(undefined);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files.length > 0) {
      setFile(e.target.files[0]);
    } else {
      setFile(undefined);
    }
  };

  const handleDownload = async() => {
    if (file) {
      try {
        const csvString = await convertCsvToString(file);
        const newCsvString = addCsvStringRow(csvString, ["4", "qux"]);
        const csv = convertStringToCsv(newCsvString);
        downloadFile(csv, "hoge");
      } catch (error) {
        console.error(error);
      }
    } else {
      console.error("ファイルが選択されていません");
    }  
  };

  return (
    <>
      <input onChange={handleFileChange} name="file" type="file" accept=".csv" />
      <button onClick={handleDownload}>ダウンロード</button>
    </>
  );
};
```

```tsx: csv関連の関数
export const convertCsvToString = async(file: File): Promise<string> => {
  if (!(file && file.type === "text/csv")) {
    throw new Error("csvファイルではありません");
  }
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = (event) => {
      const csvString = event.target?.result as string;
      resolve(csvString);
    };
    reader.onerror = () => {
      reject(new Error("ファイルの読み込みに失敗しました"));
    };
    reader.readAsText(file);
  });
};

export const addCsvStringRow = (csvString: string, newRow: string[]): string => {
  if (!csvString.endsWith("\n")) {
    csvString += "\n";
  }
  csvString += newRow.join(",") + "\n";
  return csvString;
};

export const convertStringToCsv = (csvString: string): Blob => {
  const blob = new Blob([csvString], { type: 'text/csv' });
  return blob;
};

export const downloadFile = (blob: Blob, fileName: string): void => {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = fileName;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
};
```

以下のような`sample_csv_1.csv`を選択し、

| id | name |
|:---|:-----|
| 1 | foo |
| 2 | bar |
| 3 | baz |

ダウンロードボタンを押すと、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3640003/dfd00f61-2bf4-9256-57aa-b2a60d7ba9b4.png)

以下のような`hoge.csv`がダウンロードできました！

| id | name |
|:---|:-----|
| 1 | foo |
| 2 | bar |
| 3 | baz |
| 4 | qux |


# （ひとりごと）
## 奥が深かった画像プレビュー表示の実装

画像プレビュー表示について、最初は以下のように書いていました。
（`objectUrls`をstate変数にしている）

```tsx:state変数が冗長？
import React, { useState, useEffect } from "react";

export const FileInputs = () => {
  const [files, setFiles] = useState<File[]>([]);
  const [objectUrls, setObjectUrls] = useState<string[]>([]);

  useEffect(() => {
    // objectUrls変更時とアンマウント時にオブジェクトURLを解放する
    return () => {
      objectUrls.forEach(url => URL.revokeObjectURL(url));
    };
  }, [objectUrls]);

  const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      const files = Array.from(e.target.files);
      setFiles(files);
      setObjectUrls(files.map(file => URL.createObjectURL(file)));
    } else {
      setFiles([]);
      setObjectUrls([]);
    }
  };

  return (
  // 省略
  );
};
```

[React公式チュートリアル](https://ja.react.dev/learn/choosing-the-state-structure#avoid-redundant-state)を見返していたところ、
「**レンダー中にコンポーネントの props や既存の state 変数から情報を計算できる場合、その情報をコンポーネントの state に入れるべきではありません。**」
という記載がありました。

`objectUrls`も**既存のstate変数(`files`)から計算している**と思い[^2]、`objectUrls`をstate変数から普通の変数にしてみたコードが、冒頭で紹介したものです。
changeハンドラの中で複数のset関数を呼び出す必要もなくなりました。

[^2]: `file`が同じでも`URL.createObjectURL(file)`が実行されるたびに、異なるオブジェクトURLを生成しているので、計算といえるかはよくわからないですが...

```tsx
import React, { useState, useEffect } from "react";

export const FileInputs = () => {
  const [files, setFiles] = useState<File[]>([]);
  
  // objectUrlsはfilesから生成
  const objectUrls = files.map(file => URL.createObjectURL(file));

  useEffect(() => {
    // objectUrls変更時とアンマウント時にオブジェクトURLを解放する
    return () => {
      objectUrls.forEach(url => URL.revokeObjectURL(url));
    };
  }, [objectUrls]);

  const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(Array.from(e.target.files));
    } else {
      setFiles([]);
    }
  };

  return (
  // 省略
  );
};
```

しかし上記の場合、レンダリングされるたびに`URL.createObjectURL()`が実行されます。
つまり`files`に変更がなくても、（他のstate変数やpropsの変更などにより）レンダリングされるたびに、`objectUrls`の値も再生成されてしまいます（そして値も変わる）。
問題なく動くので大丈夫かもしれませんが、気になったので色々調べてみました。
結論、[React公式チュートリアル](https://ja.react.dev/learn/you-might-not-need-an-effect#caching-expensive-calculations)にもあるように、`useMemo()`を使うことによって、`files`に変更が生じた場合のみ、オブジェクトURLを再生成するように改良できました。

```tsx:useMemo()を使って少し改良
import React, { useState, useEffect, useMemo } from "react";

export const FileInputs = () => {
  const [files, setFiles] = useState<File[]>([]);
  const [foo, setFoo] = useState<string>(""); // 更新の多いstate変数があると仮定
  
  // マウント時とfilesの変更時にのみobjectUrlsを生成
  const objectUrls = useMemo(() => {
    return files.map(file => URL.createObjectURL(file));
  }, [files]);

  useEffect(() => {
    // objectUrls変更時とアンマウント時にオブジェクトURLを解放する
    return () => {
      objectUrls.forEach(url => URL.revokeObjectURL(url));
    };
  }, [objectUrls]);

  const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(Array.from(e.target.files));
    } else {
      setFiles([]);
    }
  };

  return (
  // 省略
  );
};
```

ちなみに、以下のように`useEffect()`内で`setObjectUrls()`するコードは却下しました。
[React公式チュートリアル](https://ja.react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state)の内容を踏まえて理由を説明すると、`setFiles()`が呼び出された後、**更新前の`objectUrls`でレンダー処理を最後まで行ない**、その後`useEffect()`のセットアップ関数内の`setObjectUrls()`が呼び出され、**更新後の`objectUrls`で再レンダーをやり直す**ことになり、効率が悪いらしいからです。
（`useEffect()`のセットアップ関数は、レンダー処理が終わったあとに実行されるらしい）

```tsx:レンダー処理が2回？
import React, { useState, useEffect } from "react";

export const FileInputs = () => {
  const [files, setFiles] = useState<File[]>([]);
  const [objectUrls, setObjectUrls] = useState<string[]>([]);

  useEffect(() => {
    setObjectUrls(files.map(file => URL.createObjectURL(file)));
  }, [files])

  useEffect(() => {
    // objectUrls変更時とアンマウント時にオブジェクトURLを解放する
    return () => {
      objectUrls.forEach(url => URL.revokeObjectURL(url));
    };
  }, [objectUrls]);

  const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(Array.from(e.target.files));
    } else {
      setFiles([]);
    }
  };

  return (
  // 省略
  );
};
```

また、`URL.revokeObjectURL(url)`については`useEffect()`を使って、`objectUrl`変更時に実行しています。`files`変更時に`URL.revokeObjectURL(url)`を実行するほうが直感的かもしれないですが、`useEffect()`の依存配列の関係で`objectUrls`変更時に`URL.revokeObjectURL(url)`を実行しています。

`useEffect()`を使わない実装も以下のように考えてみました。具体的には`setFiles()`で`files`が変更される前に実行しています。
しかし、以下のコードの場合、コンポーネントのアンマウント時に`URL.revokeObjectURL(url)`が実行されないという欠点があったので、これも却下しました。

```tsx:アンマウント時にURLが解放されない？
import React, { useState } from "react";

export const FileInputs = () => {
  const [files, setFiles] = useState<File[]>([]);
  
  const objectUrls = files.map(file => URL.createObjectURL(file));

  const handleFilesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // files変更前にオブジェクトURLを解放する
    objectUrls.forEach(url => URL.revokeObjectURL(url));
    if (e.target.files) {
      setFiles(Array.from(e.target.files));
    } else {
      setFiles([]);
    }
  };

  return (
  // 省略
  );
};
```

`URL.createObjectURL(file))`と`URL.revokeObjectURL(url)`の扱いについて、もっといい方法があれば教えてください...