Files
cosscom__coss/apps/origin/docs/use-file-upload.md
2025-10-23 16:20:53 +02:00

18 KiB

useFileUpload Hook

A flexible and feature-rich React hook for handling file uploads with drag-and-drop support, file validation, and preview generation.

Note: This hook provides a solid foundation for file uploads but is designed to be extended. You can build upon it to implement additional features like pause/resume functionality, chunked uploads, retry mechanisms, or integration with specific backend services.

Features

  • 📁 Single or multiple file uploads
  • 🖱️ Drag and drop support
  • 🔍 File type validation
  • 📏 File size validation
  • 🖼️ Image preview generation
  • 🧹 Duplicate file detection
  • ⚠️ Error handling
  • 🔄 Progress tracking integration
  • 🎛️ Fully customizable UI

Installation

This hook is part of the component library and doesn't require separate installation.

Basic Usage

import { useFileUpload } from "@/registry/default/hooks/use-file-upload"

function FileUploadComponent() {
  const [
    { files, isDragging, errors },
    {
      handleDragEnter,
      handleDragLeave,
      handleDragOver,
      handleDrop,
      handleFileChange,
      openFileDialog,
      removeFile,
      clearFiles,
      getInputProps,
    },
  ] = useFileUpload({
    multiple: true,
    maxFiles: 5,
    maxSize: 5 * 1024 * 1024, // 5MB
    accept: "image/*",
  })

  return (
    <div
      onDragEnter={handleDragEnter}
      onDragLeave={handleDragLeave}
      onDragOver={handleDragOver}
      onDrop={handleDrop}
    >
      <input {...getInputProps()} />

      <button onClick={openFileDialog}>Select Files</button>

      {files.length > 0 && (
        <div>
          <h3>Selected Files:</h3>
          <ul>
            {files.map((file) => (
              <li key={file.id}>
                {file.file.name} ({formatBytes(file.file.size)})
                <button onClick={() => removeFile(file.id)}>Remove</button>
              </li>
            ))}
          </ul>
          <button onClick={clearFiles}>Clear All</button>
        </div>
      )}

      {errors.length > 0 && (
        <div style={{ color: 'red' }}>
          {errors.map((error, index) => (
            <p key={index}>{error}</p>
          ))}
        </div>
      )}
    </div>
  )
}

API Reference

Hook Parameters

The useFileUpload hook accepts a configuration object with the following options:

Option Type Default Description
maxFiles number Infinity Maximum number of files allowed (only used when multiple is true)
maxSize number Infinity Maximum file size in bytes
accept string "*" Comma-separated list of accepted file types (e.g., "image/*,application/pdf")
multiple boolean false Whether to allow multiple file selection
initialFiles FileMetadata[] [] Initial files to populate the uploader with
onFilesChange (files: FileWithPreview[]) => void undefined Callback function called whenever the files array changes
onFilesAdded (addedFiles: FileWithPreview[]) => void undefined Callback function called when new files are added

Return Value

The hook returns a tuple with two elements:

State Object

Property Type Description
files FileWithPreview[] Array of files with preview URLs
isDragging boolean Whether files are being dragged over the drop area
errors string[] Array of error messages

Actions Object

Method Type Description
addFiles (files: FileList | File[]) => void Add files programmatically
removeFile (id: string) => void Remove a file by its ID
clearFiles () => void Remove all files
clearErrors () => void Clear all error messages
handleDragEnter (e: DragEvent<HTMLElement>) => void Handle drag enter event
handleDragLeave (e: DragEvent<HTMLElement>) => void Handle drag leave event
handleDragOver (e: DragEvent<HTMLElement>) => void Handle drag over event
handleDrop (e: DragEvent<HTMLElement>) => void Handle drop event
handleFileChange (e: ChangeEvent<HTMLInputElement>) => void Handle file input change event
openFileDialog () => void Open the file selection dialog
getInputProps (props?: InputHTMLAttributes<HTMLInputElement>) => InputHTMLAttributes<HTMLInputElement> & { ref: React.Ref<HTMLInputElement> } Get props for the file input element

Types

type FileMetadata = {
  name: string
  size: number
  type: string
  url: string
  id: string
}

type FileWithPreview = {
  file: File | FileMetadata
  id: string
  preview?: string
}

Advanced Usage

Tracking Upload Progress with Server Integration

Here's a real-world example of tracking file upload progress with server integration:

import { useState } from "react"
import { useFileUpload, type FileWithPreview } from "./use-file-upload"

// Type for tracking upload progress
type UploadProgress = {
  fileId: string
  progress: number
  completed: boolean
  error?: string
}

function FileUploader() {
  const maxSize = 5 * 1024 * 1024 // 5MB

  // State to track upload progress for each file
  const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([])

  // Function to handle file upload to server
  const uploadFileToServer = async (file: File): Promise<{ url: string }> => {
    return new Promise(async (resolve, reject) => {
      try {
        // Create FormData
        const formData = new FormData()
        formData.append('file', file)

        // Create XMLHttpRequest to track progress
        const xhr = new XMLHttpRequest()

        // Track upload progress
        xhr.upload.addEventListener('progress', (event) => {
          if (event.lengthComputable) {
            const progressPercent = Math.round((event.loaded / event.total) * 100)
            // Update progress state for this file
            setUploadProgress(prev => prev.map(item =>
              item.fileId === file.name ? { ...item, progress: progressPercent } : item
            ))
          }
        })

        // Handle completion
        xhr.addEventListener('load', () => {
          if (xhr.status >= 200 && xhr.status < 300) {
            const response = JSON.parse(xhr.responseText)
            // Mark as completed
            setUploadProgress(prev => prev.map(item =>
              item.fileId === file.name ? { ...item, completed: true } : item
            ))
            resolve(response)
          } else {
            // Handle error
            setUploadProgress(prev => prev.map(item =>
              item.fileId === file.name ? { ...item, error: 'Upload failed' } : item
            ))
            reject(new Error('Upload failed'))
          }
        })

        // Handle error
        xhr.addEventListener('error', () => {
          setUploadProgress(prev => prev.map(item =>
            item.fileId === file.name ? { ...item, error: 'Network error' } : item
          ))
          reject(new Error('Network error'))
        })

        // Open and send the request
        xhr.open('POST', '/api/upload', true)
        xhr.send(formData)
      } catch (error) {
        reject(error)
      }
    })
  }

  // Handle newly added files
  const handleFilesAdded = (addedFiles: FileWithPreview[]) => {
    // Initialize progress tracking for each new file
    const newProgressItems = addedFiles.map(file => ({
      fileId: file.id,
      progress: 0,
      completed: false
    }))

    // Add new progress items to state
    setUploadProgress(prev => [...prev, ...newProgressItems])

    // Start upload for each file
    addedFiles.forEach(file => {
      if (file.file instanceof File) {
        uploadFileToServer(file.file)
          .then(response => {
            console.log('Upload successful:', response.url)
          })
          .catch(error => {
            console.error('Upload failed:', error)
          })
      }
    })
  }

  // Remove the progress tracking for the file
  const handleFileRemoved = (fileId: string) => {
    setUploadProgress(prev => prev.filter(item => item.fileId !== fileId))
  }

  const [
    { files, isDragging, errors },
    {
      handleDragEnter,
      handleDragLeave,
      handleDragOver,
      handleDrop,
      openFileDialog,
      removeFile,
      clearFiles,
      getInputProps,
    },
  ] = useFileUpload({
    multiple: true,
    maxSize,
    onFilesAdded: handleFilesAdded,
  })

  return (
    <div className="flex flex-col gap-2">
      {/* Drop area */}
      <div
        onDragEnter={handleDragEnter}
        onDragLeave={handleDragLeave}
        onDragOver={handleDragOver}
        onDrop={handleDrop}
        data-dragging={isDragging || undefined}
        data-files={files.length > 0 || undefined}
        className="border-input data-[dragging=true]:bg-accent/50 has-[input:focus]:border-ring has-[input:focus]:ring-ring/50 relative flex min-h-52 flex-col items-center overflow-hidden rounded-xl border border-dashed p-4 transition-colors not-data-[files]:justify-center has-[input:focus]:ring-[3px]"
      >
        <input
          {...getInputProps()}
          className="sr-only"
          aria-label="Upload image file"
        />
        {files.length > 0 ? (
          <div className="flex w-full flex-col gap-3">
            <div className="flex items-center justify-between gap-2">
              <h3 className="truncate text-sm font-medium">
                Files ({files.length})
              </h3>
              <div className="flex gap-2">
                <Button variant="outline" size="sm" onClick={openFileDialog}>
                  <UploadIcon
                    className="-ms-0.5 size-3.5 opacity-60"
                    aria-hidden="true"
                  />
                  Add files
                </Button>
                <Button
                  variant="outline"
                  size="sm"
                  onClick={() => {
                    // Clear all progress tracking
                    setUploadProgress([])
                    clearFiles()
                  }}
                >
                  <Trash2Icon
                    className="-ms-0.5 size-3.5 opacity-60"
                    aria-hidden="true"
                  />
                  Remove all
                </Button>
              </div>
            </div>

            <div className="w-full space-y-2">
              {files.map((file) => {
                const fileProgress = uploadProgress.find(
                  (p) => p.fileId === file.id
                )
                const isUploading = fileProgress && !fileProgress.completed

                return (
                  <div
                    key={file.id}
                    data-uploading={isUploading || undefined}
                    className="bg-background flex flex-col gap-1 rounded-lg border p-2 pe-3 transition-opacity duration-300"
                  >
                    <div className="flex items-center justify-between gap-2">
                      <div className="flex items-center gap-3 overflow-hidden in-data-[uploading=true]:opacity-50">
                        <div className="flex aspect-square size-10 shrink-0 items-center justify-center rounded border">
                          {getFileIcon(file)}
                        </div>
                        <div className="flex min-w-0 flex-col gap-0.5">
                          <p className="truncate text-[13px] font-medium">
                            {file.file instanceof File
                              ? file.file.name
                              : file.file.name}
                          </p>
                          <p className="text-muted-foreground text-xs">
                            {formatBytes(
                              file.file instanceof File
                                ? file.file.size
                                : file.file.size
                            )}
                          </p>
                        </div>
                      </div>
                      <Button
                        size="icon"
                        variant="ghost"
                        className="text-muted-foreground/80 hover:text-foreground -me-2 size-8 hover:bg-transparent"
                        onClick={() => {
                          handleFileRemoved(file.id)
                          removeFile(file.id)
                        }}
                        aria-label="Remove file"
                      >
                        <XIcon className="size-4" aria-hidden="true" />
                      </Button>
                    </div>

                    {/* Upload progress bar */}
                    {fileProgress &&
                      (() => {
                        const progress = fileProgress.progress || 0
                        const completed = fileProgress.completed || false

                        if (completed) return null

                        return (
                          <div className="mt-1 flex items-center gap-2">
                            <div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
                              <div
                                className="bg-primary h-full transition-all duration-300 ease-out"
                                style={{ width: `${progress}%` }}
                              />
                            </div>
                            <span className="text-muted-foreground w-10 text-xs tabular-nums">
                              {progress}%
                            </span>
                          </div>
                        )
                      })()}
                  </div>
                )
              })}
            </div>
          </div>
        ) : (
          <div className="flex flex-col items-center justify-center px-4 py-3 text-center">
            <div
              className="bg-background mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border"
              aria-hidden="true"
            >
              <ImageIcon className="size-4 opacity-60" />
            </div>
            <p className="mb-1.5 text-sm font-medium">Drop your files here</p>
            <p className="text-muted-foreground text-xs">
              Max {maxFiles} files ∙ Up to {maxSizeMB}MB
            </p>
            <Button variant="outline" className="mt-4" onClick={openFileDialog}>
              <UploadIcon className="-ms-1 opacity-60" aria-hidden="true" />
              Select images
            </Button>
          </div>
        )}
      </div>

      {errors.length > 0 && (
        <div
          className="text-destructive flex items-center gap-1 text-xs"
          role="alert"
        >
          <AlertCircleIcon className="size-3 shrink-0" />
          <span>{errors[0]}</span>
        </div>
      )}
    </div>
  )
}

Helper Functions

formatBytes

Formats a byte value into a human-readable string.

function formatBytes(bytes: number, decimals = 2): string

Example:

formatBytes(1024) // "1 KB"
formatBytes(1536, 1) // "1.5 KB"

Extending the Hook

The useFileUpload hook is designed as a starting point that handles the core functionality of file selection and validation. You can extend it to build more advanced features:

Pause and Resume Uploads

You can implement pause/resume functionality by using the XMLHttpRequest abort method and tracking upload progress:

const uploadWithPauseResume = (file: File) => {
  let xhr: XMLHttpRequest | null = new XMLHttpRequest();
  let isPaused = false;
  let uploadedBytes = 0;

  const pause = () => {
    if (xhr && !isPaused) {
      xhr.abort();
      isPaused = true;
    }
  };

  const resume = () => {
    if (isPaused) {
      // Create a new request
      xhr = new XMLHttpRequest();

      // Set up a Content-Range header to resume from where we left off
      const formData = new FormData();
      formData.append('file', file);

      xhr.open('POST', '/api/upload', true);
      xhr.setRequestHeader('Content-Range', `bytes ${uploadedBytes}-${file.size-1}/${file.size}`);

      // Set up progress tracking again
      xhr.upload.addEventListener('progress', (event) => {
        if (event.lengthComputable) {
          uploadedBytes = event.loaded;
          // Update progress UI
        }
      });

      xhr.send(formData);
      isPaused = false;
    }
  };

  return { pause, resume };
};

Chunked Uploads

For large files, you might want to implement chunked uploads:

const uploadInChunks = (file: File, chunkSize = 1024 * 1024) => {
  let currentChunk = 0;
  const totalChunks = Math.ceil(file.size / chunkSize);

  const uploadNextChunk = async () => {
    if (currentChunk >= totalChunks) {
      // All chunks uploaded
      return;
    }

    const start = currentChunk * chunkSize;
    const end = Math.min(file.size, start + chunkSize);
    const chunk = file.slice(start, end);

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

    try {
      await fetch('/api/upload-chunk', {
        method: 'POST',
        body: formData
      });

      currentChunk++;
      // Update progress UI
      const progress = Math.round((currentChunk / totalChunks) * 100);

      // Continue with next chunk
      uploadNextChunk();
    } catch (error) {
      // Handle error, implement retry logic
    }
  };

  // Start the upload process
  uploadNextChunk();
};

More Examples

For more examples and live demos of the useFileUpload hook in action, visit the File upload page.

The documentation site includes various implementations and use cases, including:

  • Basic file uploaders
  • Image galleries
  • Document uploaders with progress tracking
  • Drag and drop interfaces
  • Custom validation examples
  • And more