12k
All articles

Reactのカスタムファイルアップロードコンポーネントの構築

ドラッグ&ドロップ、バリデーション、プレビュー、XHR進捗追跡を備えた非制御のReactカスタムファイルアップロードコンポーネントの構築方法を解説する。

OpenReplay Team
OpenReplay Team
Reactのカスタムファイルアップロードコンポーネントの構築

ネイティブのファイル入力は機能しますが、見た目が悪く、柔軟性に欠け、アップロード中のフィードバックがゼロです。ほとんどの開発者はこれを解決するためにreact-dropzoneのようなライブラリに手を伸ばします。しかし、完全な制御が必要な場合や、依存関係を減らしたい場合、制約を理解すれば、カスタムReactファイルアップロードコンポーネントの構築は難しくありません。

このガイドでは、アクセシブルなファイル選択、オプションのドラッグ&ドロップ機能、バリデーション、プレビュー、進捗追跡、キャンセルなどの基本事項を取り上げます。すべて最新の関数コンポーネントとフックを使用します。

重要なポイント

  • ファイル入力は、プログラムによる値の割り当てを防ぐブラウザのセキュリティ制限により、Reactでは本質的に非制御コンポーネントです
  • MIMEタイプとaccept属性はバイパス可能なため、クライアント側のバリデーションには常にサーバー側のバリデーションを組み合わせてください
  • 信頼性の高いアップロード進捗追跡には、fetchではなくXMLHttpRequestを使用してください
  • ファイルプレビュー用に作成されたオブジェクトURLは、メモリリークを防ぐために必ず解放してください
  • ドラッグ&ドロップは、キーボードとクリック操作の代替ではなく、プログレッシブエンハンスメントとして実装すべきです

ファイル入力が非制御のままである理由

React開発者がつまずくポイントがあります。<input type="file">の値をプログラムで設定することはできません。ブラウザのセキュリティがそれを防いでいます。つまり、ファイル入力は本質的に非制御であり、Reactはそこから読み取ることはできますが、書き込むことはできません。

これはReact 19のForm Actionsにも関係します。useFormStatusは保留状態を追跡できますが、Server Actionsは大きなファイルのアップロード用に設計されていません。ファイルをどこかに送信する前に、クライアント側で処理する必要があります。

基礎:アクセシブルなファイル選択

入力自体から始めましょう。視覚的には非表示にしますが、アクセシブルに保ちます:

const FileUploader = () => {
  const inputRef = useRef<HTMLInputElement>(null)
  const [files, setFiles] = useState<File[]>([])

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(Array.from(e.target.files))
    }
  }

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        id="file-upload"
        className="sr-only"
        onChange={handleChange}
        multiple
        aria-describedby="file-upload-help"
      />
      <label htmlFor="file-upload" className="upload-button">
        Select files
      </label>
      <span id="file-upload-help" className="sr-only">
        Accepted formats: images, PDFs up to 10MB
      </span>
    </div>
  )
}

accept属性は助言的なものに過ぎず、ユーザーはそれをバイパスできます。必ずサーバー側でバリデーションを行ってください。

プログレッシブエンハンスメントとしてのドラッグ&ドロップの追加

ドラッグ&ドロップファイルアップロード機能はUXを向上させますが、必須ではありません。キーボードとクリック操作を主要な経路として保ちます:

const [isDragging, setIsDragging] = useState(false)

const handleDragOver = (e: React.DragEvent) => {
  e.preventDefault()
  setIsDragging(true)
}

const handleDragLeave = () => setIsDragging(false)

const handleDrop = (e: React.DragEvent) => {
  e.preventDefault()
  setIsDragging(false)
  
  const droppedFiles = Array.from(e.dataTransfer.files)
  setFiles(droppedFiles)
}

注意:DataTransferItem.getAsFileSystemHandle()は最新のブラウザでディレクトリアクセスを提供しますが、サポートは限定的です。広範な互換性のためにdataTransfer.filesを使用してください。

クライアント側のバリデーション

ユーザーエクスペリエンスを向上させるために、アップロード前にバリデーションを行います:

const validateFile = (file: File): string | null => {
  const maxSize = 10 * 1024 * 1024 // 10MB
  const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
  
  if (file.size > maxSize) return 'File exceeds 10MB limit'
  if (!allowedTypes.includes(file.type)) return 'Invalid file type'
  return null
}

覚えておいてください:MIMEタイプは偽装可能です。サーバー側のバリデーションは必須です。

適切なクリーンアップを伴うファイルプレビュー

画像プレビューには、URL.createObjectURLが一時的なURLを作成します。メモリリークを防ぐために解放してください:

const [preview, setPreview] = useState<string | null>(null)

useEffect(() => {
  if (files[0]?.type.startsWith('image/')) {
    const url = URL.createObjectURL(files[0])
    setPreview(url)
    return () => URL.revokeObjectURL(url)
  }
}, [files])

進捗追跡とキャンセル

不都合な真実があります:fetchはアップロードの進捗を確実にサポートしていません。実際の進捗追跡には、XMLHttpRequestを使用してください:

const [progress, setProgress] = useState(0)
const xhrRef = useRef<XMLHttpRequest | null>(null)

const upload = (file: File) => {
  const xhr = new XMLHttpRequest()
  xhrRef.current = xhr
  
  xhr.upload.onprogress = (e) => {
    if (e.lengthComputable) {
      setProgress(Math.round((e.loaded / e.total) * 100))
    }
  }
  
  const formData = new FormData()
  formData.append('file', file)
  
  xhr.open('POST', '/api/upload')
  xhr.send(formData)
}

const cancel = () => xhrRef.current?.abort()

大きなファイルの場合は、オブジェクトストレージへの直接アップロード(S3署名付きURL)またはチャンク/マルチパートアプローチを検討してください。数ギガバイトのファイルをアプリケーションサーバー経由で送信することは、めったにうまくいきません。

エラー状態

アップロード状態を明示的に追跡します:

type UploadState = 
  | { status: 'idle' }
  | { status: 'uploading'; progress: number }
  | { status: 'success'; url: string }
  | { status: 'error'; message: string }

この判別可能なユニオンにより、レンダリングが簡単で型安全になります。

代わりにライブラリを使用すべき場合

独自のカスタムファイルアップロードReactコンポーネントを構築することは、特定の動作が必要な場合やバンドルサイズを最小化したい場合に意味があります。react-dropzoneのようなライブラリは、見逃すかもしれないエッジケースを処理しますが、望まない依存関係や意見も追加します。

コアパターンはシンプルです:非表示の入力、アクセシブルなラベル、オプションのドロップゾーン、バリデーション、進捗のためのXHR。それ以外はすべて改良です。

まとめ

カスタムファイルアップロードコンポーネントは、動作、スタイリング、バンドルサイズを正確に制御できます。主な制約は明確です:ファイル入力は非制御のままでなければならず、クライアント側のバリデーションはサーバー側のチェックを補完するものであり決して置き換えるものではなく、XMLHttpRequestは進捗追跡の信頼できる選択肢です。アクセシブルな基盤から始め、ドラッグ&ドロップをオプションの改良として重ね、プレビューのメモリクリーンアップを処理します。カスタムで構築するかライブラリに手を伸ばすかは、特定のニーズによって異なりますが、どちらの方法でも内部で何が起こっているかを理解できるようになりました。

よくある質問

Reactでファイル入力の値を空文字列に設定してリセットできないのはなぜですか?

ブラウザのセキュリティにより、ファイル入力へのプログラムによる値の割り当てが防止されています。リセットするには、refを使用してinputRef.current.value = ''を呼び出すか(クリアには機能しますが設定には機能しません)、keyプロップを変更して入力をアンマウントして再マウントします。どちらのアプローチも、ブラウザの制限を尊重しながら選択を効果的にクリアします。

個別の進捗追跡を伴う複数ファイルのアップロードをどのように処理しますか?

ファイルごとに1つずつ、アップロード状態の配列を作成します。各状態オブジェクトは、独自の進捗、ステータス、エラーを追跡します。キーとしてファイル名または生成されたIDを使用します。アップロード時には、ファイルをマップして個別のXMLHttpRequestインスタンスを作成し、各進捗コールバックで対応する状態エントリを更新します。

子要素の上をドラッグするとドラッグ&ドロップゾーンがちらつくのはなぜですか?

dragLeaveイベントは、親要素と子要素の間を移動するときに発火します。これを修正するには、isDraggingをfalseに設定する前に、関連するターゲットがドロップゾーン内に含まれているかどうかを確認するか、dragEnterでインクリメントしdragLeaveでデクリメントするカウンターを使用し、カウンターがゼロに達したときにのみ状態を更新します。

FormDataを使用すべきか、ファイルをbase64エンコード文字列として送信すべきか?

ほとんどの場合、FormDataを使用してください。バイナリデータを効率的に処理し、サーバーが期待するmultipart/form-dataエンコーディングをサポートします。Base64エンコーディングはファイルサイズを約33%増加させ、両端でより多くの処理が必要になります。APIが特別に要求する場合や、小さなファイルをJSONペイロードに埋め込む場合にのみbase64を使用してください。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.