React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

formsfile-uploaddrag-droppreviewvalidation

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.createObjectURL for image previews
  • File removal from the array
  • FormData construction for multipart upload

Deep Dive

How It Works

  • z.instanceof(File) checks that the value is a browser File object
  • File validation runs on the client — the server should re-validate
  • URL.createObjectURL creates a temporary URL for the file blob — revoke it when done to free memory
  • The hidden <input type="file"> is triggered programmatically via ref.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 serverFile is a browser API. Fix: Use separate schemas for client and server validation. On the server, validate the FormData entry directly.

  • Memory leaks with createObjectURL — Each call allocates a blob URL. Fix: Call URL.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 with setValue.

  • Large files block the main thread — Reading large files for preview can freeze the UI. Fix: Use createObjectURL (no read needed) instead of FileReader.readAsDataURL.

  • HEIC/HEIF on iOS — iOS may send HEIC files even when you specify accept="image/jpeg". Fix: Include image/heic in your accept list, or convert server-side.

Alternatives

AlternativeUse WhenDon't Use When
UploadThingYou want a managed upload service with React componentsYou need full control over upload infrastructure
react-dropzoneYou need a polished drag-and-drop library with edge cases handledA simple custom dropzone suffices
Presigned URLs (S3)You want direct browser-to-storage upload without proxying through your serverYou need server-side processing before storage
tus-js-clientYou need resumable uploads for large filesFiles are small and upload quickly

FAQs

How does z.instanceof(File) work for file validation?
  • z.instanceof(File) checks that the value is a browser File object 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; File does not exist in Node.js
Why use URL.createObjectURL instead of FileReader for image previews?
  • createObjectURL returns a blob URL instantly without reading the file contents
  • FileReader.readAsDataURL blocks 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 append in a loop for multiple files with the same field name
  • Do not set Content-Type header; 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 File class is a browser-only API and does not exist in Node.js
  • Server actions receive FormData entries, not File objects 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 accept attribute
  • The accept attribute is a hint, not a strict filter on mobile browsers
  • Fix: include image/heic in 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
  • Controller wraps 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 XMLHttpRequest with xhr.upload.addEventListener("progress", callback)
  • The progress event provides e.loaded and e.total for percentage calculation
  • fetch does not support upload progress natively