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.jsonFeatures
- Drag & Drop - Full drag-and-drop support with visual feedback via
isDraggingstate. - 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
multipleoption. - Initial Files - Pre-populate with existing files using
initialFiles. - Callbacks - React to changes with
onFilesChangeandonFilesAddedcallbacks.
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)
/* 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
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
| Option | Type | Default | Description |
|---|---|---|---|
maxFiles | number | Infinity | Maximum number of files (only when multiple). |
maxSize | number | Infinity | Maximum file size in bytes. |
accept | string | "*" | Accepted file types (e.g., "image/*,.pdf"). |
multiple | boolean | false | Allow multiple file selection. |
initialFiles | FileMetadata[] | [] | 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
| Property | Type | Description |
|---|---|---|
files | FileWithPreview[] | Array of uploaded files with preview URLs. |
isDragging | boolean | true when a file is being dragged over the zone. |
errors | string[] | Array of validation error messages. |
Action Methods
| Method | Description |
|---|---|
addFiles | Add files programmatically: addFiles(fileList). |
removeFile | Remove a file by ID: removeFile(id). |
clearFiles | Remove all files. |
clearErrors | Clear all error messages. |
openFileDialog | Programmatically open the file picker dialog. |
getInputProps | Get props for the hidden file input (includes ref, onChange, etc). |
Drag Event Handlers
| Handler | Usage |
|---|---|
handleDragEnter | Attach to onDragEnter on your drop zone. |
handleDragLeave | Attach to onDragLeave on your drop zone. |
handleDragOver | Attach to onDragOver on your drop zone. |
handleDrop | Attach to onDrop on your drop zone. |
handleFileChange | Attach 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];
};
useSessionStorageState
A React hook for managing state in sessionStorage. Preserves data across page reloads within the same tab, perfect for temporary form data or wizard progress.
useSpeechRecognition
A React hook for speech-to-text functionality using the Web Speech Recognition API. Convert voice to text in real-time with support for multiple languages, continuous listening, and interim results.