Reactで複数ファイルアップロードを実装するときに考えること
完全に理解してオレオレFileListControlComponentを作ってみたので解説します
JavaScriptのFileListの操作ってちょっと厄介ですよね.
Reactが絡むともっと厄介ですよね(本当は絡んでいないんですが)
今回は<input type="file" multiple />
においてアップロードされたファイルを管理するコンポーネントを作ったので理解を深めながら解決していきます.
React, TypeScriptでやります.
作ったもの
以下の条件を満たす複数選択可能なファイル入力のUIです.
- 選択したファイル一覧が可視化されている
- その一つ一つに削除ボタンがある
- 再度ファイルを選択すると,ファイルが追加される(再選択ではなく追加)
実際にうごくの
なにが厄介なのか
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が入力状態を何で管理するかという問題が残ります.後述します.)
FileList
かFile[]
か
考えうるもう一つの実装としては,
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