Published on

Complete Guide to File Upload in Frontend Development

Complete Guide to File Upload in Frontend Development

File upload is a fundamental feature in modern web applications, from profile pictures to document management systems. This comprehensive guide covers everything you need to know about implementing robust, secure, and user-friendly file upload functionality in frontend applications.

Table of Contents

  1. File Upload Fundamentals
  2. Basic Implementation
  3. Advanced Upload Techniques
  4. Security Considerations
  5. Performance Optimization
  6. Framework Integration
  7. Error Handling
  8. Best Practices

File Upload Fundamentals

Understanding File Objects

// File object properties
const file = document.getElementById('fileInput').files[0]

console.log({
  name: file.name, // 'document.pdf'
  size: file.size, // 1024000 (bytes)
  type: file.type, // 'application/pdf'
  lastModified: file.lastModified, // 1640995200000
  lastModifiedDate: new Date(file.lastModified),
})

File Input Types

<!-- Single file upload -->
<input type="file" id="singleFile" accept=".jpg,.png,.gif" />

<!-- Multiple file upload -->
<input type="file" id="multipleFiles" multiple accept="image/*" />

<!-- Directory upload -->
<input type="file" id="directoryUpload" webkitdirectory />

<!-- Capture from camera -->
<input type="file" accept="image/*" capture="environment" />

Basic Implementation

Simple File Upload

class FileUploader {
  constructor(options = {}) {
    this.apiEndpoint = options.apiEndpoint || '/api/upload'
    this.allowedTypes = options.allowedTypes || ['image/jpeg', 'image/png']
    this.maxSize = options.maxSize || 5 * 1024 * 1024 // 5MB
    this.onProgress = options.onProgress || (() => {})
    this.onSuccess = options.onSuccess || (() => {})
    this.onError = options.onError || (() => {})
  }

  async upload(file) {
    // Validate file
    if (!this.validateFile(file)) {
      return
    }

    const formData = new FormData()
    formData.append('file', file)
    formData.append('timestamp', Date.now())

    try {
      const response = await this.uploadWithProgress(formData)
      this.onSuccess(response)
      return response
    } catch (error) {
      this.onError(error)
      throw error
    }
  }

  validateFile(file) {
    if (!this.allowedTypes.includes(file.type)) {
      this.onError(new Error(`File type ${file.type} not allowed`))
      return false
    }

    if (file.size > this.maxSize) {
      this.onError(new Error(`File size exceeds ${this.maxSize} bytes`))
      return false
    }

    return true
  }

  uploadWithProgress(formData) {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest()

      xhr.upload.addEventListener('progress', (e) => {
        if (e.lengthComputable) {
          const progress = (e.loaded / e.total) * 100
          this.onProgress(progress)
        }
      })

      xhr.addEventListener('load', () => {
        if (xhr.status === 200) {
          resolve(JSON.parse(xhr.responseText))
        } else {
          reject(new Error(`Upload failed: ${xhr.status}`))
        }
      })

      xhr.addEventListener('error', () => {
        reject(new Error('Upload failed'))
      })

      xhr.open('POST', this.apiEndpoint)
      xhr.send(formData)
    })
  }
}

// Usage
const uploader = new FileUploader({
  apiEndpoint: '/api/upload',
  allowedTypes: ['image/jpeg', 'image/png', 'image/gif'],
  maxSize: 10 * 1024 * 1024, // 10MB
  onProgress: (progress) => {
    console.log(`Upload progress: ${progress.toFixed(2)}%`)
  },
  onSuccess: (response) => {
    console.log('Upload successful:', response)
  },
  onError: (error) => {
    console.error('Upload error:', error.message)
  },
})

document.getElementById('fileInput').addEventListener('change', (e) => {
  const file = e.target.files[0]
  if (file) {
    uploader.upload(file)
  }
})

Multiple File Upload

class MultiFileUploader {
  constructor(options = {}) {
    this.options = options
    this.queue = []
    this.concurrent = options.concurrent || 3
    this.activeUploads = 0
  }

  async uploadMultiple(files) {
    const fileArray = Array.from(files)
    this.queue = fileArray.map((file) => ({
      file,
      status: 'pending',
      progress: 0,
      id: this.generateId(),
    }))

    this.processQueue()
    return this.waitForCompletion()
  }

  async processQueue() {
    while (this.queue.length > 0 && this.activeUploads < this.concurrent) {
      const item = this.queue.find((item) => item.status === 'pending')
      if (!item) break

      item.status = 'uploading'
      this.activeUploads++

      try {
        const result = await this.uploadSingle(item)
        item.status = 'completed'
        item.result = result
      } catch (error) {
        item.status = 'failed'
        item.error = error
      }

      this.activeUploads--
      this.processQueue() // Continue processing
    }
  }

  async uploadSingle(item) {
    const formData = new FormData()
    formData.append('file', item.file)
    formData.append('fileId', item.id)

    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest()

      xhr.upload.addEventListener('progress', (e) => {
        if (e.lengthComputable) {
          item.progress = (e.loaded / e.total) * 100
          this.updateProgress()
        }
      })

      xhr.addEventListener('load', () => {
        if (xhr.status === 200) {
          resolve(JSON.parse(xhr.responseText))
        } else {
          reject(new Error(`Upload failed: ${xhr.status}`))
        }
      })

      xhr.addEventListener('error', () => {
        reject(new Error('Network error'))
      })

      xhr.open('POST', this.options.apiEndpoint || '/api/upload')
      xhr.send(formData)
    })
  }

  updateProgress() {
    const totalProgress = this.queue.reduce((sum, item) => sum + item.progress, 0)
    const averageProgress = totalProgress / this.queue.length

    if (this.options.onProgress) {
      this.options.onProgress(averageProgress, this.queue)
    }
  }

  waitForCompletion() {
    return new Promise((resolve) => {
      const checkCompletion = () => {
        const allDone = this.queue.every(
          (item) => item.status === 'completed' || item.status === 'failed'
        )

        if (allDone) {
          resolve(this.queue)
        } else {
          setTimeout(checkCompletion, 100)
        }
      }

      checkCompletion()
    })
  }

  generateId() {
    return Date.now().toString(36) + Math.random().toString(36).substr(2)
  }
}

Advanced Upload Techniques

Drag and Drop Upload

class DragDropUploader {
  constructor(dropZone, options = {}) {
    this.dropZone = dropZone
    this.options = options
    this.setupEventListeners()
  }

  setupEventListeners() {
    const events = ['dragenter', 'dragover', 'dragleave', 'drop']

    events.forEach((eventName) => {
      this.dropZone.addEventListener(eventName, this.preventDefaults, false)
    })

    this.dropZone.addEventListener('dragenter', this.handleDragEnter.bind(this))
    this.dropZone.addEventListener('dragover', this.handleDragOver.bind(this))
    this.dropZone.addEventListener('dragleave', this.handleDragLeave.bind(this))
    this.dropZone.addEventListener('drop', this.handleDrop.bind(this))
  }

  preventDefaults(e) {
    e.preventDefault()
    e.stopPropagation()
  }

  handleDragEnter(e) {
    this.dropZone.classList.add('drag-over')
  }

  handleDragOver(e) {
    e.dataTransfer.dropEffect = 'copy'
  }

  handleDragLeave(e) {
    if (!this.dropZone.contains(e.relatedTarget)) {
      this.dropZone.classList.remove('drag-over')
    }
  }

  handleDrop(e) {
    this.dropZone.classList.remove('drag-over')

    const files = Array.from(e.dataTransfer.files)
    const items = Array.from(e.dataTransfer.items)

    // Handle directory uploads
    if (items.length > 0 && items[0].webkitGetAsEntry) {
      this.handleDirectoryUpload(items)
    } else {
      this.handleFileUpload(files)
    }
  }

  async handleDirectoryUpload(items) {
    const files = []

    for (const item of items) {
      const entry = item.webkitGetAsEntry()
      if (entry) {
        const entryFiles = await this.traverseDirectory(entry)
        files.push(...entryFiles)
      }
    }

    this.handleFileUpload(files)
  }

  traverseDirectory(entry, path = '') {
    return new Promise((resolve) => {
      if (entry.isFile) {
        entry.file((file) => {
          file.relativePath = path + file.name
          resolve([file])
        })
      } else if (entry.isDirectory) {
        const dirReader = entry.createReader()
        const files = []

        const readEntries = () => {
          dirReader.readEntries(async (entries) => {
            if (entries.length === 0) {
              resolve(files)
            } else {
              for (const childEntry of entries) {
                const childFiles = await this.traverseDirectory(childEntry, path + entry.name + '/')
                files.push(...childFiles)
              }
              readEntries()
            }
          })
        }

        readEntries()
      }
    })
  }

  handleFileUpload(files) {
    if (this.options.onFilesSelected) {
      this.options.onFilesSelected(files)
    }
  }
}

// Usage
const dropZone = document.getElementById('dropZone')
const dragDropUploader = new DragDropUploader(dropZone, {
  onFilesSelected: (files) => {
    console.log('Files selected:', files)
    // Process files here
  },
})

Chunked Upload for Large Files

class ChunkedUploader {
  constructor(options = {}) {
    this.chunkSize = options.chunkSize || 1024 * 1024 // 1MB
    this.maxRetries = options.maxRetries || 3
    this.apiEndpoint = options.apiEndpoint || '/api/upload'
  }

  async uploadFile(file) {
    const totalChunks = Math.ceil(file.size / this.chunkSize)
    const uploadId = this.generateUploadId()

    console.log(`Starting chunked upload: ${totalChunks} chunks`)

    for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
      await this.uploadChunk(file, chunkIndex, totalChunks, uploadId)
    }

    return this.finalizeUpload(uploadId, file.name)
  }

  async uploadChunk(file, chunkIndex, totalChunks, uploadId) {
    const start = chunkIndex * this.chunkSize
    const end = Math.min(start + this.chunkSize, file.size)
    const chunk = file.slice(start, end)

    const formData = new FormData()
    formData.append('chunk', chunk)
    formData.append('chunkIndex', chunkIndex)
    formData.append('totalChunks', totalChunks)
    formData.append('uploadId', uploadId)
    formData.append('fileName', file.name)

    let retries = 0
    while (retries < this.maxRetries) {
      try {
        const response = await fetch(`${this.apiEndpoint}/chunk`, {
          method: 'POST',
          body: formData,
        })

        if (response.ok) {
          console.log(`Chunk ${chunkIndex + 1}/${totalChunks} uploaded`)
          return
        }

        throw new Error(`HTTP ${response.status}`)
      } catch (error) {
        retries++
        if (retries === this.maxRetries) {
          throw new Error(`Failed to upload chunk ${chunkIndex} after ${this.maxRetries} retries`)
        }

        // Exponential backoff
        await this.delay(1000 * Math.pow(2, retries - 1))
      }
    }
  }

  async finalizeUpload(uploadId, fileName) {
    const response = await fetch(`${this.apiEndpoint}/finalize`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        uploadId,
        fileName,
      }),
    })

    if (!response.ok) {
      throw new Error(`Failed to finalize upload: ${response.status}`)
    }

    return response.json()
  }

  generateUploadId() {
    return Date.now().toString(36) + Math.random().toString(36).substr(2)
  }

  delay(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms))
  }
}

Security Considerations

File Type Validation

class FileValidator {
  static validateFileType(file, allowedTypes) {
    // Check MIME type
    if (!allowedTypes.includes(file.type)) {
      return { valid: false, error: 'File type not allowed' }
    }

    // Check file extension
    const extension = file.name.split('.').pop().toLowerCase()
    const allowedExtensions = allowedTypes
      .map((type) => {
        const extensionMap = {
          'image/jpeg': ['jpg', 'jpeg'],
          'image/png': ['png'],
          'image/gif': ['gif'],
          'application/pdf': ['pdf'],
          'text/plain': ['txt'],
        }
        return extensionMap[type] || []
      })
      .flat()

    if (!allowedExtensions.includes(extension)) {
      return { valid: false, error: 'File extension not allowed' }
    }

    return { valid: true }
  }

  static async validateFileSignature(file) {
    const signatures = {
      'image/jpeg': [0xff, 0xd8, 0xff],
      'image/png': [0x89, 0x50, 0x4e, 0x47],
      'image/gif': [0x47, 0x49, 0x46],
      'application/pdf': [0x25, 0x50, 0x44, 0x46],
    }

    const signature = signatures[file.type]
    if (!signature) {
      return { valid: true } // No signature to check
    }

    const buffer = await file.slice(0, signature.length).arrayBuffer()
    const bytes = new Uint8Array(buffer)

    const matches = signature.every((byte, index) => bytes[index] === byte)

    return {
      valid: matches,
      error: matches ? null : 'File signature does not match type',
    }
  }

  static sanitizeFileName(fileName) {
    return fileName
      .replace(/[^a-zA-Z0-9.-]/g, '_')
      .replace(/_{2,}/g, '_')
      .replace(/^_|_$/g, '')
      .substring(0, 255)
  }
}

Malware Scanning

class MalwareScanner {
  static async scanFile(file) {
    // Client-side heuristics (basic checks)
    const suspiciousExtensions = ['exe', 'bat', 'cmd', 'com', 'pif', 'scr', 'vbs', 'js']

    const extension = file.name.split('.').pop().toLowerCase()
    if (suspiciousExtensions.includes(extension)) {
      return { safe: false, reason: 'Potentially dangerous file type' }
    }

    // Check for embedded scripts in images
    if (file.type.startsWith('image/')) {
      const result = await this.checkImageForScripts(file)
      if (!result.safe) {
        return result
      }
    }

    return { safe: true }
  }

  static async checkImageForScripts(file) {
    const text = await file.text()
    const scriptPatterns = [/<script/i, /javascript:/i, /vbscript:/i, /onload=/i, /onerror=/i]

    for (const pattern of scriptPatterns) {
      if (pattern.test(text)) {
        return { safe: false, reason: 'Script detected in image file' }
      }
    }

    return { safe: true }
  }
}

Performance Optimization

Image Compression

class ImageCompressor {
  static async compressImage(file, options = {}) {
    const { maxWidth = 1920, maxHeight = 1080, quality = 0.8, format = 'image/jpeg' } = options

    return new Promise((resolve) => {
      const canvas = document.createElement('canvas')
      const ctx = canvas.getContext('2d')
      const img = new Image()

      img.onload = () => {
        // Calculate new dimensions
        const { width, height } = this.calculateDimensions(
          img.width,
          img.height,
          maxWidth,
          maxHeight
        )

        canvas.width = width
        canvas.height = height

        // Draw and compress
        ctx.drawImage(img, 0, 0, width, height)

        canvas.toBlob(resolve, format, quality)
      }

      img.src = URL.createObjectURL(file)
    })
  }

  static calculateDimensions(width, height, maxWidth, maxHeight) {
    const ratio = Math.min(maxWidth / width, maxHeight / height)

    if (ratio < 1) {
      return {
        width: Math.floor(width * ratio),
        height: Math.floor(height * ratio),
      }
    }

    return { width, height }
  }
}

Upload Queue Management

class UploadQueue {
  constructor(options = {}) {
    this.concurrency = options.concurrency || 3
    this.queue = []
    this.active = []
    this.completed = []
    this.failed = []
  }

  add(uploadTask) {
    const task = {
      id: this.generateId(),
      ...uploadTask,
      status: 'queued',
      progress: 0,
      retries: 0,
    }

    this.queue.push(task)
    this.processQueue()
    return task.id
  }

  async processQueue() {
    while (this.queue.length > 0 && this.active.length < this.concurrency) {
      const task = this.queue.shift()
      this.active.push(task)

      this.executeTask(task)
    }
  }

  async executeTask(task) {
    task.status = 'uploading'

    try {
      const result = await task.upload()
      task.status = 'completed'
      task.result = result

      this.moveToCompleted(task)
    } catch (error) {
      task.error = error

      if (task.retries < 3) {
        task.retries++
        task.status = 'retrying'

        // Add back to queue for retry
        setTimeout(() => {
          this.queue.unshift(task)
          this.processQueue()
        }, 1000 * Math.pow(2, task.retries))
      } else {
        task.status = 'failed'
        this.moveToFailed(task)
      }
    }
  }

  moveToCompleted(task) {
    this.active = this.active.filter((t) => t.id !== task.id)
    this.completed.push(task)
    this.processQueue()
  }

  moveToFailed(task) {
    this.active = this.active.filter((t) => t.id !== task.id)
    this.failed.push(task)
    this.processQueue()
  }

  generateId() {
    return Date.now().toString(36) + Math.random().toString(36).substr(2)
  }

  getStatus() {
    return {
      queued: this.queue.length,
      active: this.active.length,
      completed: this.completed.length,
      failed: this.failed.length,
    }
  }
}

Framework Integration

React Hook

import { useState, useCallback, useRef } from 'react'

export const useFileUpload = (options = {}) => {
  const [files, setFiles] = useState([])
  const [uploading, setUploading] = useState(false)
  const [progress, setProgress] = useState(0)
  const [error, setError] = useState(null)

  const uploaderRef = useRef(null)

  const uploadFiles = useCallback(
    async (fileList) => {
      setUploading(true)
      setError(null)
      setProgress(0)

      try {
        const uploader = new MultiFileUploader({
          ...options,
          onProgress: (progress) => setProgress(progress),
        })

        const results = await uploader.uploadMultiple(fileList)
        setFiles(results)
        return results
      } catch (err) {
        setError(err.message)
        throw err
      } finally {
        setUploading(false)
      }
    },
    [options]
  )

  const removeFile = useCallback((fileId) => {
    setFiles((prev) => prev.filter((f) => f.id !== fileId))
  }, [])

  const reset = useCallback(() => {
    setFiles([])
    setProgress(0)
    setError(null)
    setUploading(false)
  }, [])

  return {
    files,
    uploading,
    progress,
    error,
    uploadFiles,
    removeFile,
    reset,
  }
}

// Usage in component
const FileUploadComponent = () => {
  const { files, uploading, progress, error, uploadFiles } = useFileUpload({
    apiEndpoint: '/api/upload',
    maxSize: 10 * 1024 * 1024,
  })

  const handleFileChange = (e) => {
    const selectedFiles = Array.from(e.target.files)
    uploadFiles(selectedFiles)
  }

  return (
    <div>
      <input type="file" multiple onChange={handleFileChange} />
      {uploading && <div>Upload Progress: {progress.toFixed(2)}%</div>}
      {error && <div>Error: {error}</div>}
      {files.map((file) => (
        <div key={file.id}>
          {file.file.name} - {file.status}
        </div>
      ))}
    </div>
  )
}

Vue 3 Composition API

import { ref, computed } from 'vue'

export function useFileUpload(options = {}) {
  const files = ref([])
  const uploading = ref(false)
  const progress = ref(0)
  const error = ref(null)

  const uploadFiles = async (fileList) => {
    uploading.value = true
    error.value = null
    progress.value = 0

    try {
      const uploader = new MultiFileUploader({
        ...options,
        onProgress: (prog) => (progress.value = prog),
      })

      const results = await uploader.uploadMultiple(fileList)
      files.value = results
      return results
    } catch (err) {
      error.value = err.message
      throw err
    } finally {
      uploading.value = false
    }
  }

  const removeFile = (fileId) => {
    files.value = files.value.filter((f) => f.id !== fileId)
  }

  const completedFiles = computed(() => files.value.filter((f) => f.status === 'completed'))

  const failedFiles = computed(() => files.value.filter((f) => f.status === 'failed'))

  return {
    files,
    uploading,
    progress,
    error,
    completedFiles,
    failedFiles,
    uploadFiles,
    removeFile,
  }
}

Best Practices

1. User Experience

// Show file preview
const createFilePreview = (file) => {
  if (file.type.startsWith('image/')) {
    const reader = new FileReader()
    reader.onload = (e) => {
      const preview = document.createElement('img')
      preview.src = e.target.result
      preview.style.maxWidth = '200px'
      document.getElementById('preview').appendChild(preview)
    }
    reader.readAsDataURL(file)
  }
}

// Progress indication
const showProgress = (progress) => {
  const progressBar = document.getElementById('progressBar')
  progressBar.style.width = `${progress}%`
  progressBar.textContent = `${Math.round(progress)}%`
}

// Error handling with user feedback
const handleUploadError = (error) => {
  const errorMap = {
    'File too large': 'Please select a smaller file (max 10MB)',
    'Invalid file type': 'Please select a valid image file',
    'Network error': 'Please check your connection and try again',
  }

  const userMessage = errorMap[error.message] || 'Upload failed. Please try again.'
  showNotification(userMessage, 'error')
}

2. Performance Tips

// Lazy loading for large file lists
const LazyFileList = {
  itemHeight: 50,
  visibleItems: 10,

  render(files, container) {
    const startIndex = Math.floor(container.scrollTop / this.itemHeight)
    const endIndex = Math.min(startIndex + this.visibleItems, files.length)

    container.innerHTML = ''

    for (let i = startIndex; i < endIndex; i++) {
      const item = this.createFileItem(files[i])
      item.style.top = `${i * this.itemHeight}px`
      container.appendChild(item)
    }
  },
}

// Memory management
const cleanupFileReferences = (files) => {
  files.forEach((file) => {
    if (file.preview) {
      URL.revokeObjectURL(file.preview)
    }
  })
}

Conclusion

File upload functionality is crucial for modern web applications. By implementing proper validation, security measures, and performance optimizations, you can create robust and user-friendly upload experiences.

Key Takeaways:

  • Validate files on both client and server side
  • Use chunked uploads for large files
  • Implement proper error handling and retry logic
  • Optimize performance with compression and lazy loading
  • Secure your uploads with type validation and scanning
  • Provide great UX with progress indicators and previews

Ready to implement file uploads in your application? Start with the basic examples and gradually add advanced features as needed.