Fiber UI LogoFiberUI

useFileUpload

A comprehensive React hook for handling file uploads with drag-and-drop support, file validation, preview generation, and multiple file management.

Installation

npx shadcn@latest add https://r.fiberui.com/r/hooks/use-file-upload.json

Features

  • Drag & Drop - Full drag-and-drop support with visual feedback via isDragging state.
  • File Validation - Validate file types (accept), sizes (maxSize), and count (maxFiles).
  • Preview Generation - Automatic preview URLs for images using URL.createObjectURL.
  • Multiple Files - Support for single or multiple file uploads with multiple option.
  • Initial Files - Pre-populate with existing files using initialFiles.
  • Callbacks - React to changes with onFilesChange and onFilesAdded callbacks.

Source Code

View the full hook implementation in the Hook Source Code section below.


Examples

Single File Upload

Basic single file upload with image preview. The hidden input is connected via getInputProps().

/* eslint-disable */

"use client";

import { useFileUpload, formatBytes } from "@repo/hooks/form/use-file-upload";

export function Example1() {
    const { files, errors, removeFile, getInputProps } = useFileUpload({
        maxSize: 5 * 1024 * 1024, // 5MB
        accept: "image/*",
    });

    const file = files[0];

    return (
        <div className="flex w-full max-w-md flex-col items-center gap-4 p-4">
            <div className="w-full">
                <label
                    htmlFor="file-upload-1"
                    className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex cursor-pointer items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors"
                >
                    {file ? "Change File" : "Select Image"}
                </label>
                <input
                    id="file-upload-1"
                    className="sr-only"
                    {...getInputProps()}
                />
            </div>

            {errors.length > 0 && (
                <div className="w-full rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950">
                    {errors.map((error, i) => (
                        <p
                            key={i}
                            className="text-sm text-red-600 dark:text-red-400"
                        >
                            {error}
                        </p>
                    ))}
                </div>
            )}

            {file && (
                <div className="bg-muted/50 w-full rounded-lg border p-4">
                    <div className="flex items-start gap-4">
                        {file.preview && (
                            <img
                                src={file.preview}
                                alt={file.file.name}
                                className="h-20 w-20 rounded-md object-cover"
                            />
                        )}
                        <div className="flex-1 space-y-1">
                            <p className="truncate text-sm font-medium">
                                {file.file.name}
                            </p>
                            <p className="text-muted-foreground text-xs">
                                {formatBytes(file.file.size)}
                            </p>
                        </div>
                        <button
                            onClick={() => removeFile(file.id)}
                            className="text-muted-foreground hover:text-destructive rounded-md p-1 transition-colors"
                            aria-label="Remove file"
                        >
                            <svg
                                className="h-4 w-4"
                                xmlns="http://www.w3.org/2000/svg"
                                viewBox="0 0 24 24"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth="2"
                                strokeLinecap="round"
                                strokeLinejoin="round"
                            >
                                <path d="M18 6 6 18" />
                                <path d="m6 6 12 12" />
                            </svg>
                        </button>
                    </div>
                </div>
            )}
        </div>
    );
}

Drag & Drop with File List

Multiple file upload with a full drag-and-drop zone. Uses isDragging state for visual feedback and displays files in a list format.

Click to upload or drag and drop

Up to 5 files, max 10MB each

/* eslint-disable */
"use client";

import { useFileUpload, formatBytes } from "@repo/hooks/form/use-file-upload";

export function Example2() {
    const {
        files,
        isDragging,
        errors,
        handleDragEnter,
        handleDragLeave,
        handleDragOver,
        handleDrop,
        openFileDialog,
        removeFile,
        getInputProps,
        clearFiles,
    } = useFileUpload({
        multiple: true,
        maxFiles: 5,
        maxSize: 10 * 1024 * 1024, // 10MB
    });

    return (
        <div className="flex w-full max-w-lg flex-col gap-4 p-4">
            <input className="sr-only" {...getInputProps()} />

            {/* Dropzone */}
            <div
                onDragEnter={handleDragEnter}
                onDragLeave={handleDragLeave}
                onDragOver={handleDragOver}
                onDrop={handleDrop}
                onClick={openFileDialog}
                className={`flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors ${
                    isDragging
                        ? "border-primary bg-primary/5"
                        : "border-muted-foreground/25 hover:border-primary/50"
                }`}
            >
                <svg
                    className="text-muted-foreground mb-4 h-10 w-10"
                    xmlns="http://www.w3.org/2000/svg"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                >
                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
                    <polyline points="17 8 12 3 7 8" />
                    <line x1="12" x2="12" y1="3" y2="15" />
                </svg>
                <p className="text-muted-foreground mb-1 text-sm font-medium">
                    {isDragging
                        ? "Drop files here"
                        : "Click to upload or drag and drop"}
                </p>
                <p className="text-muted-foreground text-xs">
                    Up to 5 files, max 10MB each
                </p>
            </div>

            {/* Error messages */}
            {errors.length > 0 && (
                <div className="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950">
                    {errors.map((error: string, i: number) => (
                        <p
                            key={i}
                            className="text-sm text-red-600 dark:text-red-400"
                        >
                            {error}
                        </p>
                    ))}
                </div>
            )}

            {/* File list */}
            {files.length > 0 && (
                <div className="space-y-2">
                    <div className="flex items-center justify-between">
                        <p className="text-sm font-medium">
                            {files.length} file{files.length > 1 ? "s" : ""}{" "}
                            selected
                        </p>
                        <button
                            onClick={clearFiles}
                            className="text-muted-foreground hover:text-destructive text-xs underline"
                        >
                            Clear all
                        </button>
                    </div>
                    <div className="space-y-2">
                        {files.map((file) => (
                            <div
                                key={file.id}
                                className="bg-muted/50 flex items-center gap-3 rounded-md border p-3"
                            >
                                {file.preview &&
                                file.file.type?.startsWith("image/") ? (
                                    <img
                                        src={file.preview}
                                        alt={file.file.name}
                                        className="h-10 w-10 rounded object-cover"
                                    />
                                ) : (
                                    <div className="bg-muted flex h-10 w-10 items-center justify-center rounded">
                                        <svg
                                            className="text-muted-foreground h-5 w-5"
                                            xmlns="http://www.w3.org/2000/svg"
                                            viewBox="0 0 24 24"
                                            fill="none"
                                            stroke="currentColor"
                                            strokeWidth="2"
                                            strokeLinecap="round"
                                            strokeLinejoin="round"
                                        >
                                            <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
                                            <path d="M14 2v4a2 2 0 0 0 2 2h4" />
                                        </svg>
                                    </div>
                                )}
                                <div className="min-w-0 flex-1">
                                    <p className="truncate text-sm font-medium">
                                        {file.file.name}
                                    </p>
                                    <p className="text-muted-foreground text-xs">
                                        {formatBytes(file.file.size)}
                                    </p>
                                </div>
                                <button
                                    onClick={() => removeFile(file.id)}
                                    className="text-muted-foreground hover:text-destructive rounded-md p-1 transition-colors"
                                    aria-label="Remove file"
                                >
                                    <svg
                                        className="h-4 w-4"
                                        xmlns="http://www.w3.org/2000/svg"
                                        viewBox="0 0 24 24"
                                        fill="none"
                                        stroke="currentColor"
                                        strokeWidth="2"
                                        strokeLinecap="round"
                                        strokeLinejoin="round"
                                    >
                                        <path d="M18 6 6 18" />
                                        <path d="m6 6 12 12" />
                                    </svg>
                                </button>
                            </div>
                        ))}
                    </div>
                </div>
            )}
        </div>
    );
}

Image Grid with Initial Files

Image-specific uploader that displays files in a responsive grid. Demonstrates using initialFiles to pre-populate the uploader.

Uploaded Files (3)

image-01.jpg
image-02.jpg
image-03.jpg
/* eslint-disable */

"use client";

import { useFileUpload } from "@repo/hooks/form/use-file-upload";

// Sample initial files for demo
const initialFiles = [
    {
        id: "image-01-demo",
        name: "image-01.jpg",
        size: 1528737,
        type: "image/jpeg",
        url: "https://picsum.photos/400/400?random=1",
    },
    {
        id: "image-02-demo",
        name: "image-02.jpg",
        size: 1024000,
        type: "image/jpeg",
        url: "https://picsum.photos/400/400?random=2",
    },
    {
        id: "image-03-demo",
        name: "image-03.jpg",
        size: 2048000,
        type: "image/jpeg",
        url: "https://picsum.photos/400/400?random=3",
    },
];

export function Example3() {
    const maxSizeMB = 5;
    const maxSize = maxSizeMB * 1024 * 1024;
    const maxFiles = 6;

    const {
        files,
        isDragging,
        errors,
        handleDragEnter,
        handleDragLeave,
        handleDragOver,
        handleDrop,
        openFileDialog,
        removeFile,
        getInputProps,
    } = useFileUpload({
        accept: "image/svg+xml,image/png,image/jpeg,image/jpg,image/gif",
        initialFiles,
        maxFiles,
        maxSize,
        multiple: true,
    });

    return (
        <div className="flex w-full max-w-xl flex-col gap-2 p-4">
            {/* Drop area */}
            <div
                className={`relative flex min-h-52 flex-col overflow-hidden rounded-xl border border-dashed p-4 transition-colors ${
                    isDragging ? "border-primary bg-accent/50" : "border-input"
                } ${files.length === 0 ? "items-center justify-center" : ""}`}
                onDragEnter={handleDragEnter}
                onDragLeave={handleDragLeave}
                onDragOver={handleDragOver}
                onDrop={handleDrop}
            >
                <input
                    {...getInputProps()}
                    aria-label="Upload image file"
                    className="sr-only"
                />

                {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">
                                Uploaded Files ({files.length})
                            </h3>
                            <button
                                disabled={files.length >= maxFiles}
                                onClick={openFileDialog}
                                className="bg-background hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
                            >
                                <svg
                                    className="h-3.5 w-3.5 opacity-60"
                                    xmlns="http://www.w3.org/2000/svg"
                                    viewBox="0 0 24 24"
                                    fill="none"
                                    stroke="currentColor"
                                    strokeWidth="2"
                                    strokeLinecap="round"
                                    strokeLinejoin="round"
                                >
                                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
                                    <polyline points="17 8 12 3 7 8" />
                                    <line x1="12" x2="12" y1="3" y2="15" />
                                </svg>
                                Add more
                            </button>
                        </div>

                        <div className="grid grid-cols-2 gap-4 md:grid-cols-3">
                            {files.map((file) => (
                                <div
                                    className="bg-accent relative aspect-square rounded-md"
                                    key={file.id}
                                >
                                    <img
                                        alt={file.file.name}
                                        className="size-full rounded-[inherit] object-cover"
                                        src={file.preview}
                                    />
                                    <button
                                        aria-label="Remove image"
                                        className="bg-primary text-primary-foreground border-background absolute -right-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full border-2"
                                        onClick={() => removeFile(file.id)}
                                    >
                                        <svg
                                            className="h-3.5 w-3.5"
                                            xmlns="http://www.w3.org/2000/svg"
                                            viewBox="0 0 24 24"
                                            fill="none"
                                            stroke="currentColor"
                                            strokeWidth="2"
                                            strokeLinecap="round"
                                            strokeLinejoin="round"
                                        >
                                            <path d="M18 6 6 18" />
                                            <path d="m6 6 12 12" />
                                        </svg>
                                    </button>
                                </div>
                            ))}
                        </div>
                    </div>
                ) : (
                    <div className="flex flex-col items-center justify-center px-4 py-3 text-center">
                        <div
                            aria-hidden="true"
                            className="bg-background mb-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-full border"
                        >
                            <svg
                                className="h-4 w-4 opacity-60"
                                xmlns="http://www.w3.org/2000/svg"
                                viewBox="0 0 24 24"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth="2"
                                strokeLinecap="round"
                                strokeLinejoin="round"
                            >
                                <rect
                                    width="18"
                                    height="18"
                                    x="3"
                                    y="3"
                                    rx="2"
                                    ry="2"
                                />
                                <circle cx="9" cy="9" r="2" />
                                <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
                            </svg>
                        </div>
                        <p className="mb-1.5 text-sm font-medium">
                            Drop your images here
                        </p>
                        <p className="text-muted-foreground text-xs">
                            SVG, PNG, JPG or GIF (max. {maxSizeMB}MB)
                        </p>
                        <button
                            className="bg-background hover:bg-accent mt-4 inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition-colors"
                            onClick={openFileDialog}
                        >
                            <svg
                                className="h-4 w-4 opacity-60"
                                xmlns="http://www.w3.org/2000/svg"
                                viewBox="0 0 24 24"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth="2"
                                strokeLinecap="round"
                                strokeLinejoin="round"
                            >
                                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
                                <polyline points="17 8 12 3 7 8" />
                                <line x1="12" x2="12" y1="3" y2="15" />
                            </svg>
                            Select images
                        </button>
                    </div>
                )}
            </div>

            {errors.length > 0 && (
                <div
                    className="text-destructive flex items-center gap-1 text-xs"
                    role="alert"
                >
                    <svg
                        className="h-3 w-3 shrink-0"
                        xmlns="http://www.w3.org/2000/svg"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="2"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                    >
                        <circle cx="12" cy="12" r="10" />
                        <line x1="12" x2="12" y1="8" y2="12" />
                        <line x1="12" x2="12.01" y1="16" y2="16" />
                    </svg>
                    <span>{errors[0]}</span>
                </div>
            )}
        </div>
    );
}

File List with Type Icons

General file uploader with file type detection. Shows different icons based on file type (PDF, ZIP, Excel, images, etc.).

Upload files

Drag & drop or click to browse

All filesMax 10 filesUp to 100 MB

document.pdf

516.34 KB

project.zip

246.95 KB

data.xlsx

344.6 KB

"use client";

import {
    useFileUpload,
    formatBytes,
    type FileWithPreview,
} from "@repo/hooks/form/use-file-upload";

// Sample initial files for demo
const initialFiles = [
    {
        id: "document-pdf-demo",
        name: "document.pdf",
        size: 528737,
        type: "application/pdf",
        url: "https://example.com/document.pdf",
    },
    {
        id: "archive-zip-demo",
        name: "project.zip",
        size: 252873,
        type: "application/zip",
        url: "https://example.com/project.zip",
    },
    {
        id: "spreadsheet-xlsx-demo",
        name: "data.xlsx",
        size: 352873,
        type: "application/xlsx",
        url: "https://example.com/data.xlsx",
    },
];

// Get appropriate icon based on file type
function getFileIcon(file: FileWithPreview) {
    const fileType =
        file.file instanceof File ? file.file.type : file.file.type;
    const fileName =
        file.file instanceof File ? file.file.name : file.file.name;

    // PDF/Word documents
    if (
        fileType.includes("pdf") ||
        fileName.endsWith(".pdf") ||
        fileType.includes("word") ||
        fileName.endsWith(".doc") ||
        fileName.endsWith(".docx")
    ) {
        return (
            <svg
                className="h-4 w-4 opacity-60"
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
            >
                <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
                <path d="M14 2v4a2 2 0 0 0 2 2h4" />
                <path d="M10 9H8" />
                <path d="M16 13H8" />
                <path d="M16 17H8" />
            </svg>
        );
    }

    // Archives
    if (
        fileType.includes("zip") ||
        fileType.includes("archive") ||
        fileName.endsWith(".zip") ||
        fileName.endsWith(".rar")
    ) {
        return (
            <svg
                className="h-4 w-4 opacity-60"
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
            >
                <path d="M16 22h2a2 2 0 0 0 2-2V7l-5-5H6a2 2 0 0 0-2 2v18" />
                <path d="M14 2v4a2 2 0 0 0 2 2h4" />
                <circle cx="10" cy="20" r="2" />
                <path d="M10 7V6" />
                <path d="M10 12v-1" />
                <path d="M10 18v-2" />
            </svg>
        );
    }

    // Spreadsheets
    if (
        fileType.includes("excel") ||
        fileName.endsWith(".xls") ||
        fileName.endsWith(".xlsx")
    ) {
        return (
            <svg
                className="h-4 w-4 opacity-60"
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
            >
                <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
                <path d="M14 2v4a2 2 0 0 0 2 2h4" />
                <path d="M8 13h2" />
                <path d="M14 13h2" />
                <path d="M8 17h2" />
                <path d="M14 17h2" />
            </svg>
        );
    }

    // Images
    if (fileType.startsWith("image/")) {
        return (
            <svg
                className="h-4 w-4 opacity-60"
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
            >
                <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
                <circle cx="9" cy="9" r="2" />
                <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
            </svg>
        );
    }

    // Video
    if (fileType.includes("video/")) {
        return (
            <svg
                className="h-4 w-4 opacity-60"
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
            >
                <path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" />
                <rect x="2" y="6" width="14" height="12" rx="2" />
            </svg>
        );
    }

    // Audio
    if (fileType.includes("audio/")) {
        return (
            <svg
                className="h-4 w-4 opacity-60"
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
            >
                <path d="M3 14h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 18 0v7a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3" />
            </svg>
        );
    }

    // Default file icon
    return (
        <svg
            className="h-4 w-4 opacity-60"
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="2"
            strokeLinecap="round"
            strokeLinejoin="round"
        >
            <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
            <path d="M14 2v4a2 2 0 0 0 2 2h4" />
        </svg>
    );
}

export function Example4() {
    const maxSize = 100 * 1024 * 1024; // 100MB
    const maxFiles = 10;

    const {
        files,
        isDragging,
        errors,
        handleDragEnter,
        handleDragLeave,
        handleDragOver,
        handleDrop,
        openFileDialog,
        removeFile,
        clearFiles,
        getInputProps,
    } = useFileUpload({
        initialFiles,
        maxFiles,
        maxSize,
        multiple: true,
    });

    return (
        <div className="flex w-full max-w-lg flex-col gap-2 p-4">
            {/* Drop area */}
            <div
                className={`hover:bg-accent/50 flex min-h-40 cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed p-4 transition-colors ${
                    isDragging ? "border-primary bg-accent/50" : "border-input"
                }`}
                onClick={openFileDialog}
                onDragEnter={handleDragEnter}
                onDragLeave={handleDragLeave}
                onDragOver={handleDragOver}
                onDrop={handleDrop}
                role="button"
                tabIndex={-1}
            >
                <input
                    {...getInputProps()}
                    aria-label="Upload files"
                    className="sr-only"
                />

                <div className="flex flex-col items-center justify-center text-center">
                    <div
                        aria-hidden="true"
                        className="bg-background mb-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-full border"
                    >
                        <svg
                            className="h-4 w-4 opacity-60"
                            xmlns="http://www.w3.org/2000/svg"
                            viewBox="0 0 24 24"
                            fill="none"
                            stroke="currentColor"
                            strokeWidth="2"
                            strokeLinecap="round"
                            strokeLinejoin="round"
                        >
                            <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
                            <path d="M14 2v4a2 2 0 0 0 2 2h4" />
                            <path d="M12 12v6" />
                            <path d="m15 15-3-3-3 3" />
                        </svg>
                    </div>
                    <p className="mb-1.5 text-sm font-medium">Upload files</p>
                    <p className="text-muted-foreground mb-2 text-xs">
                        Drag & drop or click to browse
                    </p>
                    <div className="text-muted-foreground/70 flex flex-wrap justify-center gap-1 text-xs">
                        <span>All files</span>
                        <span>∙</span>
                        <span>Max {maxFiles} files</span>
                        <span>∙</span>
                        <span>Up to {formatBytes(maxSize)}</span>
                    </div>
                </div>
            </div>

            {errors.length > 0 && (
                <div
                    className="text-destructive flex items-center gap-1 text-xs"
                    role="alert"
                >
                    <svg
                        className="h-3 w-3 shrink-0"
                        xmlns="http://www.w3.org/2000/svg"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="2"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                    >
                        <circle cx="12" cy="12" r="10" />
                        <line x1="12" x2="12" y1="8" y2="12" />
                        <line x1="12" x2="12.01" y1="16" y2="16" />
                    </svg>
                    <span>{errors[0]}</span>
                </div>
            )}

            {/* File list */}
            {files.length > 0 && (
                <div className="space-y-2">
                    {files.map((file) => (
                        <div
                            className="bg-background flex items-center justify-between gap-2 rounded-lg border p-2 pe-3"
                            key={file.id}
                        >
                            <div className="flex min-w-0 items-center gap-3">
                                <div className="flex aspect-square h-10 w-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.name}
                                    </p>
                                    <p className="text-muted-foreground text-xs">
                                        {formatBytes(file.file.size)}
                                    </p>
                                </div>
                            </div>

                            <button
                                aria-label="Remove file"
                                className="text-muted-foreground/80 hover:text-foreground -me-2 h-8 w-8 rounded-md p-2 transition-colors hover:bg-transparent"
                                onClick={() => removeFile(file.id)}
                            >
                                <svg
                                    className="h-4 w-4"
                                    xmlns="http://www.w3.org/2000/svg"
                                    viewBox="0 0 24 24"
                                    fill="none"
                                    stroke="currentColor"
                                    strokeWidth="2"
                                    strokeLinecap="round"
                                    strokeLinejoin="round"
                                >
                                    <path d="M18 6 6 18" />
                                    <path d="m6 6 12 12" />
                                </svg>
                            </button>
                        </div>
                    ))}

                    {/* Remove all files button */}
                    {files.length > 1 && (
                        <button
                            onClick={clearFiles}
                            className="bg-background hover:bg-accent rounded-md border px-3 py-1.5 text-sm font-medium transition-colors"
                        >
                            Remove all files
                        </button>
                    )}
                </div>
            )}
        </div>
    );
}

Common Patterns

Hidden Input Pattern

The hook manages a hidden file input that you connect via getInputProps():

<input {...getInputProps()} className="sr-only" />

Dropzone Pattern

Create a visual drop zone using the drag event handlers:

<div
    onDragEnter={handleDragEnter}
    onDragLeave={handleDragLeave}
    onDragOver={handleDragOver}
    onDrop={handleDrop}
    onClick={openFileDialog}
    className={isDragging ? "border-primary bg-primary/5" : "border-muted"}
>
    Drop files here or click to upload
</div>

Accessing File Data

Handle both File objects and FileMetadata:

files.map((file) => {
    const name = file.file.name;
    const size = file.file.size;
    const type = file.file instanceof File ? file.file.type : file.file.type;
    const preview = file.preview;
    // ...
});

API Reference

Usage

const {
    // State
    files, // Array of files with previews
    isDragging, // Whether a file is being dragged over
    errors, // Validation error messages
    // Actions
    addFiles,
    removeFile,
    clearFiles,
    clearErrors,
    openFileDialog,
    getInputProps,
    // Drag handlers
    handleDragEnter,
    handleDragLeave,
    handleDragOver,
    handleDrop,
    handleFileChange,
} = useFileUpload(options);

Options

OptionTypeDefaultDescription
maxFilesnumberInfinityMaximum number of files (only when multiple).
maxSizenumberInfinityMaximum file size in bytes.
acceptstring"*"Accepted file types (e.g., "image/*,.pdf").
multiplebooleanfalseAllow multiple file selection.
initialFilesFileMetadata[][]Initial files to populate the uploader.
onFilesChange(files: FileWithPreview[]) => void-Callback when files array changes.
onFilesAdded(addedFiles: FileWithPreview[]) => void-Callback when new files are added.

State Properties

PropertyTypeDescription
filesFileWithPreview[]Array of uploaded files with preview URLs.
isDraggingbooleantrue when a file is being dragged over the zone.
errorsstring[]Array of validation error messages.

Action Methods

MethodDescription
addFilesAdd files programmatically: addFiles(fileList).
removeFileRemove a file by ID: removeFile(id).
clearFilesRemove all files.
clearErrorsClear all error messages.
openFileDialogProgrammatically open the file picker dialog.
getInputPropsGet props for the hidden file input (includes ref, onChange, etc).

Drag Event Handlers

HandlerUsage
handleDragEnterAttach to onDragEnter on your drop zone.
handleDragLeaveAttach to onDragLeave on your drop zone.
handleDragOverAttach to onDragOver on your drop zone.
handleDropAttach to onDrop on your drop zone.
handleFileChangeAttach to onChange on file input (via getInputProps).

Types

FileMetadata

Used for initial files or server-provided file data.

type FileMetadata = {
    name: string; // File name
    size: number; // Size in bytes
    type: string; // MIME type (e.g., "image/jpeg")
    url: string; // URL for preview
    id: string; // Unique identifier
};

FileWithPreview

Returned in the files array. Can be either an uploaded File or FileMetadata.

type FileWithPreview = {
    file: File | FileMetadata; // The actual file or metadata
    id: string; // Unique identifier
    preview?: string; // Preview URL (blob or provided URL)
};

Helper Functions

formatBytes

Format bytes to human-readable format.

import { formatBytes } from "@repo/hooks/form/use-file-upload";

formatBytes(1536); // "1.5 KB"
formatBytes(1048576); // "1 MB"
formatBytes(1073741824); // "1 GB"

Hook Source Code

"use client";

import {
    type ChangeEvent,
    type DragEvent,
    type InputHTMLAttributes,
    useCallback,
    useRef,
    useState,
} from "react";

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

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

export type FileUploadOptions = {
    /** Maximum number of files (only used when multiple is true). Defaults to Infinity. */
    maxFiles?: number;
    /** Maximum file size in bytes. Defaults to Infinity. */
    maxSize?: number;
    /** Accepted file types (e.g., "`image/*,.pdf`"). Defaults to " `*` ". */
    accept?: string;
    /** Allow multiple file selection. Defaults to false. */
    multiple?: boolean;
    /** Initial files to populate the state with. */
    initialFiles?: FileMetadata[];
    /** Callback when files change. */
    onFilesChange?: (files: FileWithPreview[]) => void;
    /** Callback when new files are added. */
    onFilesAdded?: (addedFiles: FileWithPreview[]) => void;
};

export type FileUploadState = {
    files: FileWithPreview[];
    isDragging: boolean;
    errors: string[];
};

export type FileUploadActions = {
    addFiles: (files: FileList | File[]) => void;
    removeFile: (id: string) => void;
    clearFiles: () => void;
    clearErrors: () => void;
    handleDragEnter: (e: DragEvent<HTMLElement>) => void;
    handleDragLeave: (e: DragEvent<HTMLElement>) => void;
    handleDragOver: (e: DragEvent<HTMLElement>) => void;
    handleDrop: (e: DragEvent<HTMLElement>) => void;
    handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void;
    openFileDialog: () => void;
    getInputProps: (
        props?: InputHTMLAttributes<HTMLInputElement>,
    ) => InputHTMLAttributes<HTMLInputElement> & {
        // Use `any` here to avoid cross-React ref type conflicts across packages
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        ref: any;
    };
};

export type FileUploadResult = FileUploadState & FileUploadActions;

/**
 * A comprehensive hook for handling file uploads with drag-and-drop, validation, and previews.
 *
 * @param options - Configuration options for file upload behavior.
 * @returns An object containing state and actions for managing file uploads.
 */
export const useFileUpload = (
    options: FileUploadOptions = {},
): FileUploadResult => {
    const {
        maxFiles = Number.POSITIVE_INFINITY,
        maxSize = Number.POSITIVE_INFINITY,
        accept = "*",
        multiple = false,
        initialFiles = [],
        onFilesChange,
        onFilesAdded,
    } = options;

    const [state, setState] = useState<FileUploadState>({
        errors: [],
        files: initialFiles.map((file) => ({
            file,
            id: file.id,
            preview: file.url,
        })),
        isDragging: false,
    });

    const inputRef = useRef<HTMLInputElement>(null);

    const validateFile = useCallback(
        (file: File | FileMetadata): string | null => {
            if (file instanceof File) {
                if (file.size > maxSize) {
                    return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`;
                }
            } else {
                if (file.size > maxSize) {
                    return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`;
                }
            }

            if (accept !== "*") {
                const acceptedTypes = accept
                    .split(",")
                    .map((type) => type.trim());
                const fileType =
                    file instanceof File ? file.type || "" : file.type;
                const fileExtension = `.${file instanceof File ? file.name.split(".").pop() : file.name.split(".").pop()}`;

                const isAccepted = acceptedTypes.some((type) => {
                    if (type.startsWith(".")) {
                        return (
                            fileExtension.toLowerCase() === type.toLowerCase()
                        );
                    }
                    if (type.endsWith("/*")) {
                        const baseType = type.split("/")[0];
                        return fileType.startsWith(`${baseType}/`);
                    }
                    return fileType === type;
                });

                if (!isAccepted) {
                    return `File "${file instanceof File ? file.name : file.name}" is not an accepted file type.`;
                }
            }

            return null;
        },
        [accept, maxSize],
    );

    const createPreview = useCallback(
        (file: File | FileMetadata): string | undefined => {
            if (file instanceof File) {
                return URL.createObjectURL(file);
            }
            return file.url;
        },
        [],
    );

    const generateUniqueId = useCallback(
        (file: File | FileMetadata): string => {
            if (file instanceof File) {
                return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
            }
            return file.id;
        },
        [],
    );

    const clearFiles = useCallback(() => {
        setState((prev) => {
            // Clean up object URLs
            for (const file of prev.files ?? []) {
                if (
                    file.preview &&
                    file.file instanceof File &&
                    file.file.type.startsWith("image/")
                ) {
                    URL.revokeObjectURL(file.preview);
                }
            }

            if (inputRef.current) {
                inputRef.current.value = "";
            }

            const newState = {
                ...prev,
                errors: [],
                files: [],
            };

            onFilesChange?.(newState.files);
            return newState;
        });
    }, [onFilesChange]);

    const addFiles = useCallback(
        (newFiles: FileList | File[]) => {
            if (!newFiles || newFiles.length === 0) return;

            const newFilesArray = Array.from(newFiles);
            const errors: string[] = [];

            // Clear existing errors when new files are uploaded
            setState((prev) => ({ ...prev, errors: [] }));

            // In single file mode, clear existing files first
            if (!multiple) {
                clearFiles();
            }

            // Check if adding these files would exceed maxFiles (only in multiple mode)
            if (
                multiple &&
                maxFiles !== Number.POSITIVE_INFINITY &&
                state.files.length + newFilesArray.length > maxFiles
            ) {
                errors.push(
                    `You can only upload a maximum of ${maxFiles} files.`,
                );
                setState((prev) => ({ ...prev, errors }));
                return;
            }

            const validFiles: FileWithPreview[] = [];

            for (const file of newFilesArray) {
                if (multiple) {
                    const isDuplicate = state.files.some(
                        (existingFile) =>
                            existingFile.file.name === file.name &&
                            existingFile.file.size === file.size,
                    );

                    if (isDuplicate) {
                        continue;
                    }
                }

                if (file.size > maxSize) {
                    errors.push(
                        multiple
                            ? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`
                            : `File exceeds the maximum size of ${formatBytes(maxSize)}.`,
                    );
                    continue;
                }

                const error = validateFile(file);

                if (error) {
                    errors.push(error);
                    continue;
                }

                validFiles.push({
                    file,
                    id: generateUniqueId(file),
                    preview: createPreview(file),
                });
            }

            // Only update state if we have valid files to add
            if (validFiles.length > 0) {
                // Call the onFilesAdded callback with the newly added valid files
                onFilesAdded?.(validFiles);

                setState((prev) => {
                    const newFiles = !multiple
                        ? validFiles
                        : [...prev.files, ...validFiles];
                    onFilesChange?.(newFiles);
                    return {
                        ...prev,
                        errors,
                        files: newFiles,
                    };
                });
            } else if (errors.length > 0) {
                setState((prev) => ({
                    ...prev,
                    errors,
                }));
            }

            // Reset input value after handling files
            if (inputRef.current) {
                inputRef.current.value = "";
            }
        },
        [
            state.files,
            maxFiles,
            multiple,
            maxSize,
            validateFile,
            createPreview,
            generateUniqueId,
            clearFiles,
            onFilesChange,
            onFilesAdded,
        ],
    );

    const removeFile = useCallback(
        (id: string) => {
            setState((prev) => {
                const fileToRemove = prev.files.find((file) => file.id === id);
                if (
                    fileToRemove?.preview &&
                    fileToRemove.file instanceof File &&
                    fileToRemove.file.type.startsWith("image/")
                ) {
                    URL.revokeObjectURL(fileToRemove.preview);
                }

                const newFiles = prev.files.filter((file) => file.id !== id);
                onFilesChange?.(newFiles);

                return {
                    ...prev,
                    errors: [],
                    files: newFiles,
                };
            });
        },
        [onFilesChange],
    );

    const clearErrors = useCallback(() => {
        setState((prev) => ({
            ...prev,
            errors: [],
        }));
    }, []);

    const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {
        e.preventDefault();
        e.stopPropagation();
        setState((prev) => ({ ...prev, isDragging: true }));
    }, []);

    const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {
        e.preventDefault();
        e.stopPropagation();

        if (e.currentTarget.contains(e.relatedTarget as Node)) {
            return;
        }

        setState((prev) => ({ ...prev, isDragging: false }));
    }, []);

    const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {
        e.preventDefault();
        e.stopPropagation();
    }, []);

    const handleDrop = useCallback(
        (e: DragEvent<HTMLElement>) => {
            e.preventDefault();
            e.stopPropagation();
            setState((prev) => ({ ...prev, isDragging: false }));

            // Don't process files if the input is disabled
            if (inputRef.current?.disabled) {
                return;
            }

            if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
                // In single file mode, only use the first file
                if (!multiple) {
                    const file = e.dataTransfer.files[0];
                    if (file) {
                        addFiles([file]);
                    }
                } else {
                    addFiles(e.dataTransfer.files);
                }
            }
        },
        [addFiles, multiple],
    );

    const handleFileChange = useCallback(
        (e: ChangeEvent<HTMLInputElement>) => {
            if (e.target.files && e.target.files.length > 0) {
                addFiles(e.target.files);
            }
        },
        [addFiles],
    );

    const openFileDialog = useCallback(() => {
        if (inputRef.current) {
            inputRef.current.click();
        }
    }, []);

    const getInputProps = useCallback(
        (props: InputHTMLAttributes<HTMLInputElement> = {}) => {
            return {
                ...props,
                accept: props.accept || accept,
                multiple:
                    props.multiple !== undefined ? props.multiple : multiple,
                onChange: handleFileChange,
                ref: inputRef,
                type: "file" as const,
            };
        },
        [accept, multiple, handleFileChange],
    );

    return {
        // State
        ...state,
        // Actions
        addFiles,
        clearErrors,
        clearFiles,
        getInputProps,
        handleDragEnter,
        handleDragLeave,
        handleDragOver,
        handleDrop,
        handleFileChange,
        openFileDialog,
        removeFile,
    };
};

/**
 * Helper function to format bytes to human-readable format.
 *
 * @param bytes - The number of bytes to format.
 * @param decimals - The number of decimal places. Defaults to 2.
 * @returns A formatted string (e.g., "1.5 MB").
 */
export const formatBytes = (bytes: number, decimals = 2): string => {
    if (bytes === 0) return "0 Bytes";

    const k = 1024;
    const dm = decimals < 0 ? 0 : decimals;
    const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];

    const i = Math.floor(Math.log(bytes) / Math.log(k));

    return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i];
};