Use File Upload

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

Preview

Upload files

Drag & drop or click to browse

All filesMax 10 filesUp 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, // 5MB
accept: 'image/*',
});
return (
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<input {...getInputProps()} />
<button onClick={openFileDialog}>Select Files</button>
{files.length > 0 && (
<div>
<h3>Selected Files:</h3>
<ul>
{files.map((file) => (
<li key={file.id}>
{file.file.name} ({formatBytes(file.file.size)})
<button onClick={() => removeFile(file.id)}>Remove</button>
</li>
))}
</ul>
<button onClick={clearFiles}>Clear All</button>
</div>
)}
{errors.length > 0 && (
<div style={{ color: 'red' }}>
{errors.map((error, index) => (
<p key={index}>{error}</p>
))}
</div>
)}
</div>
);
}

API Reference

Parameters

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

OptionTypeDefaultDescription
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

PropertyTypeDescription
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

MethodTypeDescription
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"