shadimage
FormCn
hookuse-file-uploadsource: ok · hit

file upload hook

state management hook for file upload component

npx shadcn add "https://formcn.dev/r/use-file-upload.json"
free

Where it goes

stats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/9/2026, 11:09:37 PM

src/form-builder/hooks/use-file-upload.tsx
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <explanation> */
import type React from 'react'

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 = {
	maxFiles?: number // Only used when multiple is true, defaults to Infinity
	maxSize?: number // in bytes
	accept?: string
	multiple?: boolean // Defaults to false
	initialFiles?: FileMetadata[]
	onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change
	onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added
}

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> & {
		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] = useState<FileUploadState>({
		files: initialFiles.map((file) => ({
			file,
			id: file.id,
			preview: file.url,
		})),
		isDragging: false,
		errors: [],
	})

	const inputRef = useRef<HTMLInputElement>(null)

	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}-${crypto.randomUUID()}`
		}
		return file.id
	}, [])

	const clearFiles = useCallback(() => {
		setState((prev) => {
			// Clean up object URLs
			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 = 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 !== 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) => {
				// Only check for duplicates if multiple files are allowed
				if (multiple) {
					const isDuplicate = state.files.some(
						(existingFile) =>
							existingFile.file.name === file.name &&
							existingFile.file.size === file.size,
					)

					// Skip duplicate files silently
					if (isDuplicate) {
						return
					}
				}

				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,
						files: newFiles,
						errors,
					}
				})
			} 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 &&
					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 = 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]
					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,
				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,
		},
	]
}

// Helper function to format bytes to human-readable format
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 / Math.pow(k, i)).toFixed(dm)) + sizes[i]
}

More from FormCn

Browse all components in this library →