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.
Recipe
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.
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, "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 tipo- Zona de arrastrar y soltar con retroalimentación visual
URL.createObjectURLpara vistas previas de imágenes- Eliminación de archivo del array
- Construcción de
FormDatapara carga multipart
Deep Dive
Cómo Funciona
z.instanceof(File)verifica que el valor es un objetoFiledel navegador- La validación de archivo se ejecuta en el cliente — el servidor debe revalidar
URL.createObjectURLcrea una URL temporal para el blob del archivo — revócala cuando hayas terminado para liberar memoria- El
<input type="file">oculto se activa programáticamente medianteref.click() setValue("images", files, { shouldValidate: true })actualiza el formulario y activa la validación
Variaciones
Archivo ú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 };
}Notas sobre TypeScript
// 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];Gotchas
-
z.instanceof(File)falla en el servidor —Filees una API del navegador. Solución: Usa esquemas separados para validación del cliente y servidor. En el servidor, valida la entrada deFormDatadirectamente. -
Fugas de memoria con
createObjectURL— Cada llamada asigna una URL de blob. Solución: Llama aURL.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 consetValue. -
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 deFileReader.readAsDataURL. -
HEIC/HEIF en iOS — iOS puede enviar archivos HEIC incluso cuando especificas
accept="image/jpeg". Solución: Incluyeimage/heicen tu lista de accept, o convierte del lado del servidor.
Alternativas
| 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 |
FAQs
¿Cómo funciona z.instanceof(File) para la validación de archivos?
z.instanceof(File)verifica que el valor es un objetoFiledel navegador en tiempo de ejecución- Encadena
.refine()para agregar verificaciones de tamaño y tipo:.refine(f => f.size <= 5_000_000, "Max 5MB") - Esto solo funciona en el cliente;
Fileno existe en Node.js
¿Por qué usar URL.createObjectURL en lugar de FileReader para vistas previas de imágenes?
createObjectURLdevuelve una URL de blob al instante sin leer el contenido del archivoFileReader.readAsDataURLbloquea el hilo principal para archivos grandes- Siempre llama a
URL.revokeObjectURL(url)cuando la vista previa se elimina para liberar memoria
¿Cómo conectas una entrada de archivo oculta a una zona de arrastrar y soltar?
<div onClick={() => inputRef.current?.click()}>
Suelta archivos aquí
<input ref={inputRef} type="file" className="hidden" onChange={handleFileChange} />
</div>- El div visible maneja eventos de clic y arrastre
- La entrada oculta se activa programáticamente mediante
ref.click()
¿Cómo construyes FormData para carga de archivo multipart?
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 });- Usa
appenden un bucle para múltiples archivos con el mismo nombre de campo - No establecer el encabezado
Content-Type; el navegador lo establece con el límite automáticamente
¿Por qué usar setValue con shouldValidate: true para entradas de archivo?
setValue("images", files, { shouldValidate: true })actualiza el valor del formulario e inmediatamente activa la validación de Zod- Sin
shouldValidate, el formulario no mostraría errores hasta el siguiente intento de envío - Esto proporciona retroalimentación instantánea sobre restricciones de tamaño/tipo de archivo
Gotcha: ¿Por qué falla z.instanceof(File) en el servidor?
- La clase
Filees una API solo del navegador y no existe en Node.js - Las Server Actions reciben entradas de
FormData, no objetosFilede tu esquema del cliente - Solución: usa esquemas separados para cliente y servidor; en el servidor, valida la entrada de FormData directamente
Gotcha: ¿Por qué iOS podría enviar archivos HEIC incluso cuando especifiques accept="image/jpeg"?
- iOS puede capturar fotos en formato HEIC independientemente del atributo
accept - El atributo
acceptes una sugerencia, no un filtro estricto en navegadores móviles - Solución: incluye
image/heicen tu lista de accept, o convierte HEIC a JPEG del lado del servidor
¿Cómo tipas el esquema de File e infiere su tipo en TypeScript?
const 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);¿Cómo tipas tipos MIME aceptados como unión en TypeScript?
const MIME_TYPES = ["image/jpeg", "image/png"] as const;
type MimeType = (typeof MIME_TYPES)[number];
// "image/jpeg" | "image/png"¿Cómo el componente Controller ayuda con entradas de archivo en react-hook-form?
- El
<input type="file">nativo es no controlado y no puede tener su valor establecido programáticamente Controllerenvuelve la entrada y gestiona el valor a través del estado de RHF- Usa
onChange={(e) => onChange(e.target.files?.[0])}dentro del prop de renderizado de Controller
¿Cómo rastreas el progreso de carga para archivos grandes?
- Usa
XMLHttpRequestconxhr.upload.addEventListener("progress", callback) - El evento de progreso proporciona
e.loadedye.totalpara el cálculo de porcentaje fetchno soporta progreso de carga nativamente
Relacionado
- React Hook Form — Controller y setValue
- Patrones de Formularios Básicos — patrones de formularios estándar
- Formularios con Server Action — carga de archivo en server actions
- Visualización de Errores de Formulario — mostrando errores de validación