Form Patterns File Upload
Validate file uploads with Zod, implement drag-and-drop, and show image previews — all with proper type safety.
Recipe
Quick-reference recipe card — copy-paste ready.
import { z } from "zod";
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const FileSchema = z
.instanceof(File)
.refine((f) => f.size <= MAX_FILE_SIZE, "File must be under 5MB")
.refine((f) => ACCEPTED_TYPES.includes(f.type), "Only JPEG, PNG, or WebP");
const UploadSchema = z.object({
title: z.string().min(1),
file: FileSchema,
});
// For multiple files
const MultiFileSchema = z.object({
files: z
.array(FileSchema)
.min(1, "At least one file")
.max(5, "Maximum 5 files"),
});When to reach for this: When your form includes file uploads that need client-side validation for type, size, or count before sending to the server.
Working Example
"use client";
import { useState, useRef, useCallback } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const MAX_SIZE = 5 * 1024 * 1024;
const ACCEPTED = ["image/jpeg", "image/png", "image/webp"];
const Schema = z.object({
title: z.string().min(1, "Title required"),
images: z
.array(
z
.instanceof(File)
.refine((f) => f.size <= MAX_SIZE, "Max 5MB per file")
.refine((f) => ACCEPTED.includes(f.type), "Only JPEG, PNG, WebP")
)
.min(1, "Upload at least one image")
.max(4, "Maximum 4 images"),
});
type FormData = z.infer<typeof Schema>;
export function ImageUploadForm() {
const [previews, setPreviews] = useState<string[]>([]);
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
setValue,
watch,
} = useForm<FormData>({
resolver: zodResolver(Schema),
defaultValues: { title: "", images: [] },
});
const images = watch("images");
const updateFiles = useCallback(
(files: File[]) => {
setValue("images", files, { shouldValidate: true });
const urls = files.map((f) => URL.createObjectURL(f));
setPreviews((prev) => {
prev.forEach(URL.revokeObjectURL);
return urls;
});
},
[setValue]
);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
updateFiles(files);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
updateFiles(files);
}
function removeFile(index: number) {
const next = images.filter((_, i) => i !== index);
updateFiles(next);
}
async function onSubmit(data: FormData) {
const fd = new FormData();
fd.append("title", data.title);
data.images.forEach((file) => fd.append("images", file));
await fetch("/api/upload", { method: "POST", body: fd });
alert("Uploaded!");
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-lg space-y-4">
<div>
<input
{...register("title")}
placeholder="Title"
className="w-full rounded border p-2"
/>
{errors.title && <p className="text-sm text-red-600">{errors.title.message}</p>}
</div>
{/* Drag-and-drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition ${
isDragging ? "border-blue-500 bg-blue-50" : "border-gray-300"
}`}
>
<p className="text-sm text-gray-600">
Drag & drop images here, or click to browse
</p>
<p className="mt-1 text-xs text-gray-400">
JPEG, PNG, WebP — max 5MB each, up to 4 files
</p>
<input
ref={inputRef}
type="file"
accept={ACCEPTED.join(",")}
multiple
onChange={handleFileChange}
className="hidden"
/>
</div>
{errors.images && (
<p className="text-sm text-red-600">
{errors.images.message ?? errors.images.root?.message}
</p>
)}
{/* Previews */}
{previews.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{previews.map((src, i) => (
<div key={src} className="relative">
<img src={src} alt={`Preview ${i + 1}`} className="h-24 w-full rounded object-cover" />
<button
type="button"
onClick={() => removeFile(i)}
className="absolute right-1 top-1 rounded-full bg-red-500 px-1.5 text-xs text-white"
>
X
</button>
</div>
))}
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Uploading..." : "Upload"}
</button>
</form>
);
}What this demonstrates:
z.instanceof(File)with size and type refinements- Drag-and-drop zone with visual feedback
URL.createObjectURLfor image previews- File removal from the array
FormDataconstruction for multipart upload
Deep Dive
How It Works
z.instanceof(File)checks that the value is a browserFileobject- File validation runs on the client — the server should re-validate
URL.createObjectURLcreates a temporary URL for the file blob — revoke it when done to free memory- The hidden
<input type="file">is triggered programmatically viaref.click() setValue("images", files, { shouldValidate: true })updates the form and triggers validation
Variations
Single file with Controller:
<Controller
name="avatar"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<div>
<input
type="file"
accept="image/*"
onChange={(e) => onChange(e.target.files?.[0])}
/>
{value && <p className="text-sm">{value.name}</p>}
{error && <p className="text-sm text-red-600">{error.message}</p>}
</div>
)}
/>Server Action file upload:
// action.ts
"use server";
export async function uploadAction(prev: State, formData: FormData) {
const file = formData.get("file") as File;
if (!file || file.size === 0) return { error: "No file selected" };
if (file.size > 5 * 1024 * 1024) return { error: "File too large" };
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await fs.writeFile(`/uploads/${file.name}`, buffer);
return { success: true };
}Progress tracking:
function useUploadProgress() {
const [progress, setProgress] = useState(0);
async function upload(file: File) {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
});
// ... XHR setup
}
return { upload, progress };
}TypeScript Notes
// File is a global browser type — no import needed
const FileSchema = z.instanceof(File);
type FileType = z.infer<typeof FileSchema>; // File
// For server-side (Node), use Buffer or Uint8Array instead
const ServerFileSchema = z.instanceof(Buffer);
// Accept attribute types
const MIME_TYPES = ["image/jpeg", "image/png"] as const;
type MimeType = (typeof MIME_TYPES)[number];Gotchas
-
z.instanceof(File)fails on the server —Fileis a browser API. Fix: Use separate schemas for client and server validation. On the server, validate theFormDataentry directly. -
Memory leaks with
createObjectURL— Each call allocates a blob URL. Fix: CallURL.revokeObjectURL(url)when the preview is removed or the component unmounts. -
<input type="file">is uncontrolled — You cannot set its value programmatically (security restriction). Fix: Use a hidden input and manage state separately withsetValue. -
Large files block the main thread — Reading large files for preview can freeze the UI. Fix: Use
createObjectURL(no read needed) instead ofFileReader.readAsDataURL. -
HEIC/HEIF on iOS — iOS may send HEIC files even when you specify
accept="image/jpeg". Fix: Includeimage/heicin your accept list, or convert server-side.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| UploadThing | You want a managed upload service with React components | You need full control over upload infrastructure |
| react-dropzone | You need a polished drag-and-drop library with edge cases handled | A simple custom dropzone suffices |
| Presigned URLs (S3) | You want direct browser-to-storage upload without proxying through your server | You need server-side processing before storage |
| tus-js-client | You need resumable uploads for large files | Files are small and upload quickly |
FAQs
How does z.instanceof(File) work for file validation?
z.instanceof(File)checks that the value is a browserFileobject at runtime- Chain
.refine()to add size and type checks:.refine(f => f.size <= 5_000_000, "Max 5MB") - This only works on the client;
Filedoes not exist in Node.js
Why use URL.createObjectURL instead of FileReader for image previews?
createObjectURLreturns a blob URL instantly without reading the file contentsFileReader.readAsDataURLblocks the main thread for large files- Always call
URL.revokeObjectURL(url)when the preview is removed to free memory
How do you wire a hidden file input to a drag-and-drop zone?
<div onClick={() => inputRef.current?.click()}>
Drop files here
<input ref={inputRef} type="file" className="hidden" onChange={handleFileChange} />
</div>- The visible div handles click and drag events
- The hidden input is triggered programmatically via
ref.click()
How do you construct FormData for multipart file upload?
const fd = new FormData();
fd.append("title", data.title);
data.images.forEach(file => fd.append("images", file));
await fetch("/api/upload", { method: "POST", body: fd });- Use
appendin a loop for multiple files with the same field name - Do not set
Content-Typeheader; the browser sets it with the boundary automatically
Why use setValue with shouldValidate: true for file inputs?
setValue("images", files, { shouldValidate: true })updates the form value and immediately triggers Zod validation- Without
shouldValidate, the form would not show errors until the next submission attempt - This provides instant feedback on file size/type constraints
Gotcha: Why does z.instanceof(File) fail on the server?
- The
Fileclass is a browser-only API and does not exist in Node.js - Server actions receive
FormDataentries, notFileobjects from your client schema - Fix: use separate schemas for client and server; on the server, validate the FormData entry directly
Gotcha: Why might iOS send HEIC files even when you specify accept="image/jpeg"?
- iOS may capture photos in HEIC format regardless of the
acceptattribute - The
acceptattribute is a hint, not a strict filter on mobile browsers - Fix: include
image/heicin your accept list, or convert HEIC to JPEG server-side
How do you type the File schema and infer its type in TypeScript?
const FileSchema = z.instanceof(File);
type FileType = z.infer<typeof FileSchema>; // File
// For server-side (Node.js), use Buffer:
const ServerFileSchema = z.instanceof(Buffer);How do you type accepted MIME types as a union in TypeScript?
const MIME_TYPES = ["image/jpeg", "image/png"] as const;
type MimeType = (typeof MIME_TYPES)[number];
// "image/jpeg" | "image/png"How does the Controller component help with file inputs in react-hook-form?
- Native
<input type="file">is uncontrolled and cannot have its value set programmatically Controllerwraps the input and manages the value through RHF's state- Use
onChange={(e) => onChange(e.target.files?.[0])}inside the Controller's render prop
How do you track upload progress for large files?
- Use
XMLHttpRequestwithxhr.upload.addEventListener("progress", callback) - The progress event provides
e.loadedande.totalfor percentage calculation fetchdoes not support upload progress natively
Related
- React Hook Form — Controller and setValue
- Form Patterns Basic — standard form patterns
- Server Action Forms — file upload in server actions
- Form Error Display — showing validation errors