Patrones de Formularios Carga de Archivos
Valida cargas de archivos con Zod, implementa arrastrar y soltar, y muestra vistas previas de imágenes - todo con seguridad de tipos adecuada.
Busca en todas las páginas de la documentación
Valida cargas de archivos con Zod, implementa arrastrar y soltar, y muestra vistas previas de imágenes - todo con seguridad de tipos adecuada.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Tarjeta de referencia rápida - lista para copiar y pegar.
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, "El archivo debe ser menor a 5MB")
.refine((f) => ACCEPTED_TYPES.includes(f.type), "Solo JPEG, PNG, o WebP");
const UploadSchema = z.object({
title: z.string().min(1),
file: FileSchema,
});
// Para múltiples archivos
const MultiFileSchema = z.object({
files: z
.array(FileSchema)
.min(1, "Al menos un archivo")
.max(5, "Máximo 5 archivos"),
});Cuándo usarlo: Cuando tu formulario incluye cargas de archivos que necesitan validación del lado del cliente para tipo, tamaño o cantidad antes de enviar al servidor.
"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, "Título requerido"),
images: z
.array(
z
.instanceof(File)
.refine((f) => f.size <= MAX_SIZE, "Máximo 5MB por archivo")
.refine((f) => ACCEPTED.includes(f.type), "Solo JPEG, PNG, WebP")
)
.min(1, "Carga al menos una imagen")
.max(4, "Máximo 4 imágenes"),
});
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("¡Cargado!");
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-lg space-y-4">
<div>
<input
{...register("title")}
placeholder="Título"
className="w-full rounded border p-2"
/>
{errors.title && <p className="text-sm text-red-600">{errors.title.message}</p>}
</div>
{/* Zona de arrastrar y soltar */}
<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">
Arrastra y suelta imágenes aquí, o haz clic para examinar
</p>
<p className="mt-1 text-xs text-gray-400">
JPEG, PNG, WebP - máximo 5MB cada una, hasta 4 archivos
</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>
)}
{/* Vistas previas */}
{previews.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{previews.map((src, i) => (
<div key={src} className="relative">
<img src={src} alt={`Vista previa ${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 ? "Cargando..." : "Cargar"}
</button>
</form>
);
}Lo que esto demuestra:
z.instanceof(File) con refinamientos de tamaño y tipoURL.createObjectURL para vistas previas de imágenesFormData para carga multipartz.instanceof(File) verifica que el valor es un objeto File del navegadorURL.createObjectURL crea una URL temporal para el blob del archivo - revócala cuando hayas terminado para liberar memoria<input type="file"> oculto se activa programáticamente mediante ref.click()setValue("images", files, { shouldValidate: true }) actualiza el formulario y activa la validaciónArchivo único con 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>
)}
/>Carga de archivo con Server Action:
// 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: "Ningún archivo seleccionado" };
if (file.size > 5 * 1024 * 1024) return { error: "Archivo demasiado grande" };
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await fs.writeFile(`/uploads/${file.name}`, buffer);
return { success: true };
}Rastreo de progreso:
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));
});
// ... configuración de XHR
}
return { upload, progress };
}// File es un tipo global del navegador - no se requiere importar
const FileSchema = z.instanceof(File);
type FileType = z.infer<typeof FileSchema>; // File
// Para el lado del servidor (Node), usa Buffer o Uint8Array en su lugar
const ServerFileSchema = z.instanceof(Buffer);
// Tipos de atributo accept
const MIME_TYPES = ["image/jpeg", "image/png"] as const;
type MimeType = (typeof MIME_TYPES)[number];z.instanceof(File) falla en el servidor - File es una API del navegador. Solución: Usa esquemas separados para validación del cliente y servidor. En el servidor, valida la entrada de FormData directamente.
Fugas de memoria con createObjectURL - Cada llamada asigna una URL de blob. Solución: Llama a URL.revokeObjectURL(url) cuando la vista previa se elimina o el componente se desmonta.
<input type="file"> es no controlado - No puedes establecer su valor programáticamente (restricción de seguridad). Solución: Usa una entrada oculta y gestiona el estado por separado con setValue.
Archivos grandes bloquean el hilo principal - Leer archivos grandes para la vista previa puede congelar la UI. Solución: Usa createObjectURL (no requiere lectura) en lugar de FileReader.readAsDataURL.
HEIC/HEIF en iOS - iOS puede enviar archivos HEIC incluso cuando especificas accept="image/jpeg". Solución: Incluye image/heic en tu lista de accept, o convierte del lado del servidor.
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
| UploadThing | Deseas un servicio de carga administrado con componentes React | Necesitas control total sobre la infraestructura de carga |
| react-dropzone | Necesitas una librería de arrastrar y soltar pulida con casos límite manejados | Un dropzone personalizado simple es suficiente |
| Presigned URLs (S3) | Deseas carga directa del navegador al almacenamiento sin pasar por tu servidor | Necesitas procesamiento del lado del servidor antes del almacenamiento |
| tus-js-client | Necesitas cargas reanudables para archivos grandes | Los archivos son pequeños y se cargan rápidamente |
z.instanceof(File) verifica que el valor es un objeto File del navegador en tiempo de ejecución.refine() para agregar verificaciones de tamaño y tipo: .refine(f => f.size <= 5_000_000, "Max 5MB")File no existe en Node.jscreateObjectURL devuelve una URL de blob al instante sin leer el contenido del archivoFileReader.readAsDataURL bloquea el hilo principal para archivos grandesURL.revokeObjectURL(url) cuando la vista previa se elimina para liberar memoria<div onClick={() => inputRef.current?.click()}>
Suelta archivos aquí
<input ref={inputRef} type="file" className="hidden" onChange={handleFileChange} />
</div>ref.click()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 });append en un bucle para múltiples archivos con el mismo nombre de campoContent-Type; el navegador lo establece con el límite automáticamentesetValue("images", files, { shouldValidate: true }) actualiza el valor del formulario e inmediatamente activa la validación de ZodshouldValidate, el formulario no mostraría errores hasta el siguiente intento de envíoFile es una API solo del navegador y no existe en Node.jsFormData, no objetos File de tu esquema del clienteacceptaccept es una sugerencia, no un filtro estricto en navegadores móvilesimage/heic en tu lista de accept, o convierte HEIC a JPEG del lado del servidorconst FileSchema = z.instanceof(File);
type FileType = z.infer<typeof FileSchema>; // File
// Para el lado del servidor (Node.js), usa Buffer:
const ServerFileSchema = z.instanceof(Buffer);const MIME_TYPES = ["image/jpeg", "image/png"] as const;
type MimeType = (typeof MIME_TYPES)[number];
// "image/jpeg" | "image/png"<input type="file"> nativo es no controlado y no puede tener su valor establecido programáticamenteController envuelve la entrada y gestiona el valor a través del estado de RHFonChange={(e) => onChange(e.target.files?.[0])} dentro del prop de renderizado de ControllerXMLHttpRequest con xhr.upload.addEventListener("progress", callback)e.loaded y e.total para el cálculo de porcentajefetch no soporta progreso de carga nativamenteRevisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥