Preview
Upload files
Drag & drop or click to browse
All files∙Max 10 files∙Up to 100MB
Installation
CLI
npx shadcn@latest add https://kelvinmai.io/r/use-file-upload.json
Manual
Copy and paste the following code into your project
hooks/use-file-upload.ts
'use client';import * as React 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 = {maxFiles?: number;maxSize?: number;accept?: string;multiple?: boolean;initialFiles?: FileMetadata[];onFilesChange?: (files: FileWithPreview[]) => void;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: React.DragEvent<HTMLElement>) => void;handleDragLeave: (e: React.DragEvent<HTMLElement>) => void;handleDragOver: (e: React.DragEvent<HTMLElement>) => void;handleDrop: (e: React.DragEvent<HTMLElement>) => void;handleFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;openFileDialog: () => void;getInputProps: (props?: React.InputHTMLAttributes<HTMLInputElement>,) => React.InputHTMLAttributes<HTMLInputElement> & {ref: React.Ref<HTMLInputElement>;};};export const useFileUpload = (options: FileUploadOptions = {},): [FileUploadState, FileUploadActions] => {const {maxFiles = Infinity,maxSize = Infinity,accept = '*',multiple = false,initialFiles = [],onFilesChange,onFilesAdded,} = options;const [state, setState] = React.useState<FileUploadState>({files: initialFiles.map((file) => ({file,id: file.id,preview: file.url,})),isDragging: false,errors: [],});const inputRef = React.useRef<HTMLInputElement>(null);const validateFile = React.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 = React.useCallback((file: File | FileMetadata): string | undefined => {if (file instanceof File) {return URL.createObjectURL(file);}return file.url;},[],);const generateUniqueId = React.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 = React.useCallback(() => {setState((prev) => {prev.files.forEach((file) => {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,files: [],errors: [],};onFilesChange?.(newState.files);return newState;});}, [onFilesChange]);const addFiles = React.useCallback((newFiles: FileList | File[]) => {if (!newFiles || newFiles.length === 0) return;const newFilesArray = Array.from(newFiles);const errors: string[] = [];setState((prev) => ({ ...prev, errors: [] }));if (!multiple) {clearFiles();}if (multiple &&maxFiles !== 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[] = [];newFilesArray.forEach((file) => {if (multiple) {const isDuplicate = state.files.some((existingFile) =>existingFile.file.name === file.name &&existingFile.file.size === file.size,);if (isDuplicate) {return;}}if (file.size > maxSize) {errors.push(multiple? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`: `File exceeds the maximum size of ${formatBytes(maxSize)}.`,);return;}const error = validateFile(file);if (error) {errors.push(error);} else {validFiles.push({file,id: generateUniqueId(file),preview: createPreview(file),});}});if (validFiles.length > 0) {onFilesAdded?.(validFiles);setState((prev) => {const newFiles = !multiple? validFiles: [...prev.files, ...validFiles];onFilesChange?.(newFiles);return {...prev,files: newFiles,errors,};});} else if (errors.length > 0) {setState((prev) => ({...prev,errors,}));}if (inputRef.current) {inputRef.current.value = '';}},[state.files.length,maxFiles,multiple,maxSize,validateFile,createPreview,generateUniqueId,clearFiles,onFilesChange,onFilesAdded,],);const removeFile = React.useCallback((id: string) => {setState((prev) => {const fileToRemove = prev.files.find((file) => file.id === id);if (fileToRemove &&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,files: newFiles,errors: [],};});},[onFilesChange],);const clearErrors = React.useCallback(() => {setState((prev) => ({...prev,errors: [],}));}, []);const handleDragEnter = React.useCallback((e: React.DragEvent<HTMLElement>) => {e.preventDefault();e.stopPropagation();setState((prev) => ({ ...prev, isDragging: true }));},[],);const handleDragLeave = React.useCallback((e: React.DragEvent<HTMLElement>) => {e.preventDefault();e.stopPropagation();if (e.currentTarget.contains(e.relatedTarget as Node)) {return;}setState((prev) => ({ ...prev, isDragging: false }));},[],);const handleDragOver = React.useCallback((e: React.DragEvent<HTMLElement>) => {e.preventDefault();e.stopPropagation();},[],);const handleDrop = React.useCallback((e: React.DragEvent<HTMLElement>) => {e.preventDefault();e.stopPropagation();setState((prev) => ({ ...prev, isDragging: false }));if (inputRef.current?.disabled) {return;}if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {if (!multiple) {const file = e.dataTransfer.files[0];addFiles([file!]);} else {addFiles(e.dataTransfer.files);}}},[addFiles, multiple],);const handleFileChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {if (e.target.files && e.target.files.length > 0) {addFiles(e.target.files);}},[addFiles],);const openFileDialog = React.useCallback(() => {if (inputRef.current) {inputRef.current.click();}}, []);const getInputProps = React.useCallback((props: React.InputHTMLAttributes<HTMLInputElement> = {}) => {return {...props,type: 'file' as const,onChange: handleFileChange,accept: props.accept || accept,multiple: props.multiple !== undefined ? props.multiple : multiple,ref: inputRef,};},[accept, multiple, handleFileChange],);return [state,{addFiles,removeFile,clearFiles,clearErrors,handleDragEnter,handleDragLeave,handleDragOver,handleDrop,handleFileChange,openFileDialog,getInputProps,},];};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));const size = sizes[i] as string;return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + size;};
Update the import paths to match your project setup
Usage
import { useFileUpload } from '@/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, // 5MBaccept: 'image/*',});return (<divonDragEnter={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
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 |
Returns
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 |
Helper Functions
formatBytes
Formats a byte value into a human-readable string.
function formatBytes(bytes: number, decimals = 2): string;
formatBytes(1024); // "1 KB"formatBytes(1536, 1); // "1.5 KB"