📁

Reactで複数ファイルアップロードを実装するときに考えること

2023/02/07に公開

完全に理解してオレオレFileListControlComponentを作ってみたので解説します

JavaScriptのFileListの操作ってちょっと厄介ですよね.

Reactが絡むともっと厄介ですよね(本当は絡んでいないんですが)

今回は<input type="file" multiple />においてアップロードされたファイルを管理するコンポーネントを作ったので理解を深めながら解決していきます.

React, TypeScriptでやります.

作ったもの

以下の条件を満たす複数選択可能なファイル入力のUIです.

  • 選択したファイル一覧が可視化されている
  • その一つ一つに削除ボタンがある
  • 再度ファイルを選択すると,ファイルが追加される(再選択ではなく追加)

実際にうごくの
https://www.nbr41.com/sandbox/input-file-list

なにが厄介なのか

Reactでは基本的にはuseStateによって定義した変数で入力状態を管理します.

基本となる<input type="text" />の場合を見てみましょう

const MyInputComponent = () => {
  const [inputText, setInputText] = useState('');

  return (
    <input
      type="text"
      value={inputText}
      onChange={(e) => setInputText(e.target.value)}
    />
  );
};

ここまでは何も問題ありません.

何が問題ないのかというと,インターフェースは一つであり,それから入力されたテキストは必ず,e.target.valueを介してinputTextに格納されるという点です.

<input type="file" />の場合

さて,ここからは実装方法が人によって異なってくるかと思います.

私はこのようにしました.

const MyInputFileComponent = () => {
  const [inputFiles, setInputFiles] = useState<FileList | null>(null);

  return (
    <input
      type="file"
      // value={inputFiles} 不要
      onChange={(e) => setInputFiles(e.target.files)}
    />
  );
};

valueをどうするか

実は,<input type="file" />に対してvalueを指定しようとすると

InvalidStateError: Failed to set the 'value' property on 'HTMLInputElement': This input element accepts a filename, which may only be programmatically set to the empty string.

というエラーになります.この形式はvalueを受け付けることができませんので空の文字列を指定してくださいとのことです.

よってひとまずinputに渡すvalueは不要です.(これは無視できることにはなりません.inputが入力状態を何で管理するかという問題が残ります.後述します.)

FileListFile[]

考えうるもう一つの実装としては,

const MyInputFileComponent = () => {
  const [inputFiles, setInputFiles] = useState<File[]>([]);

  return (
    <input
      type="file"
      // value={inputFiles} 不要
      onChange={(e) =>
        setInputFiles(e.target.files ? [...Array.from(e.target.files)] : [])
      }
    />
  );
};

よく見る形式ですが,余分なArray.from()による変換や条件分岐があるので複雑に感じます.

いずれにせよe.target.filesは複数であることに注意

e.target.filesの型を見ます.

HTMLInputElement.files: FileList | null

前提知識としてe.target.filesによって取得できるfilesは命名の通り複数です.そしてこのFileListは配列なように見えて配列ではなく,配列のメソッドは使用できません.このことが厄介の根源であると考えています.(否定的な意味ではない)

<input type="file" multiple />の場合

いよいよファイルを複数アップロードできるパターンについて考えます.

const MyInputMultiFileListComponent = () => {
  const [inputFiles, setInputFiles] = useState<FileList | null>(null);

  return (
    <input
      type="file"
      multiple
      onChange={(e) => setInputFiles(e.target.files)}
    />
  );
};

こちらは先程のものにmultipleを追加しただけです.おそらくなにも問題なく動くでしょう.

この手のコンポーネントはUIライブラリでも多く見かけます.

しかし,このコンポーネントのUX的な欠点は,「追加し直す場合に,すべてを選択し直す必要がある」という点です.

再度ファイルを選択すると,以前までの入力状態(選択したファイルたち)は失われます.

そこで,以下のような条件を満たすコンポーネントを作ります.(前述した作ったもの)

  • 選択したファイル一覧が可視化されている
  • その一つ一つに削除ボタンがある
  • 再度ファイルを選択すると,ファイルが追加される(再選択ではなく追加)

inputの選択状態を把握し,変更する必要が出てくる

最後の厄介な理由です.

inputの選択状態というのはe.target.filesの中のそれです.

しかし,e.target.filesによって取得している以上はコントロールしていることにはなりません.

なので,refを使用してコントロールしていこうと思います.

作ってみる

できた

const MyInputMultiFileListControlComponent = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  const [inputFiles, setInputFiles] = useState<FileList | null>(null);
  console.log('MyInputMultiFileListControlComponent inputFiles', inputFiles);

  const selectedFileArray: File[] = useMemo(() => {
    return inputFiles ? [...Array.from(inputFiles)] : [];
  }, [inputFiles]);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (!e.target.files) return;
    if (!inputRef.current?.files) return;
    const newFileArray = [
      ...selectedFileArray,
      ...Array.from(e.target.files),
    ].filter(
      (file, index, self) =>
        self.findIndex((f) => f.name === file.name) === index // 重複を削除
    );
    const dt = new DataTransfer();
    newFileArray.forEach((file) => dt.items.add(file));
    inputRef.current.files = dt.files; // input内のFileListを更新
    setInputFiles(dt.files); // Reactのstateを更新
  };

  const handleDelete = (index: number) => {
    if (!inputRef.current?.files) return;
    const dt = new DataTransfer();
    selectedFileArray.forEach((file, i) => i !== index && dt.items.add(file));
    inputRef.current.files = dt.files; // input内のFileListを更新
    setInputFiles(dt.files); // Reactのstateを更新
  };

  return (
    <div>
      <input type="file" multiple onChange={handleChange} ref={inputRef} />
      <div className="w-fit space-y-3">
        {selectedFileArray.map((file, index) => (
          <div
            key={file.name}
            className="flex items-center justify-between gap-2"
          >
            <div>{file.name}</div>
            <button onClick={() => handleDelete(index)}>削除</button>
          </div>
        ))}
      </div>
    </div>
  );
};

ポイントは

  • handleChangeで元のファイルに追加されたファイルを追加し,重複するものは削除する
  • handleDeleteではinputRef.current.filesに対しても削除する処理を書くこと
  • ともに,inputRef.current.filesに対する操作とinputFilesに対する操作を忘れずに行う

解説していきます.

FileListを操作する術を知る

useRefを使う

前述したようにe.target.filesだけではコントロール不可なので,useRefを使用してinputのrefをコントロールできるようにします.

const inputRef = useRef<HTMLInputElement>(null);

<input type="file" multiple onChange={handleChange} ref={inputRef} />

DataTransferを使う

FileListは配列なようで配列ではないというのが厄介ということでした.

これの操作を実現するのが,このDataTransferです.

とは言っても,new DataTransfer()として新たにFileListを作ってそれをrefに再代入しているだけなので,あまりスマートではないのかもしれません.

// handleChangeの例
newFileArray.forEach((file) => dt.items.add(file));
inputRef.current.files = dt.files; // input内のFileListを更新

dt.items.add()で追加してdt.filesとすることで,FileListの型でrefに挿入できます.

実装完了

一応これで目的のUIを実現可能です.

ReactのstateとHTMLInputElement.filesの2重管理となるのが複雑な点ですね…

Discussion