Form Patterns File Upload
Valide uploads de arquivos com Zod, implemente drag-and-drop e mostre pré-visualizações de imagens - tudo com segurança de tipo adequada.
Busque em todas as páginas da documentação
Valide uploads de arquivos com Zod, implemente drag-and-drop e mostre pré-visualizações de imagens - tudo com segurança de tipo adequada.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Cartão de receita de referência rápida - pronto para copiar e colar.
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, "O arquivo deve ter menos de 5MB")
.refine((f) => ACCEPTED_TYPES.includes(f.type), "Apenas JPEG, PNG ou WebP");
const UploadSchema = z.object({
title: z.string().min(1),
file: FileSchema,
});
// Para múltiplos arquivos
const MultiFileSchema = z.object({
files: z
.array(FileSchema)
.min(1, "Pelo menos um arquivo")
.max(5, "Máximo de 5 arquivos"),
});Quando usar isso: Quando seu formulário inclui uploads de arquivos que precisam de validação no lado do cliente para tipo, tamanho ou contagem antes de serem enviados ao 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 é obrigatório"),
images: z
.array(
z
.instanceof(File)
.refine((f) => f.size <= MAX_SIZE, "Máximo de 5MB por arquivo")
.refine((f) => ACCEPTED.includes(f.type), "Apenas JPEG, PNG, WebP")
)
.min(1, "Faça upload de pelo menos uma imagem")
.max(4, "Máximo de 4 imagens"),
});
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("Enviado!");
}
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 arrastar e 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">
Arraste e solte imagens aqui, ou clique para procurar
</p>
<p className="mt-1 text-xs text-gray-400">
JPEG, PNG, WebP - máximo 5MB cada, até 4 arquivos
</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>
)}
{/* Pré-visualizações */}
{previews.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{previews.map((src, i) => (
<div key={src} className="relative">
<img src={src} alt={`Pré-visualização ${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 ? "Enviando..." : "Enviar"}
</button>
</form>
);
}O que isso demonstra:
z.instanceof(File) com refinamentos de tamanho e tipoURL.createObjectURL para pré-visualizações de imagemFormData para upload multipartz.instanceof(File) verifica se o valor é um objeto File do navegadorURL.createObjectURL cria uma URL temporária para o blob do arquivo - revogue-a quando terminar para liberar memória<input type="file"> oculto é acionado programaticamente via ref.click()setValue("images", files, { shouldValidate: true }) atualiza o formulário e aciona a validaçãoArquivo único com 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>
)}
/>Upload de arquivo com 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: "Nenhum arquivo selecionado" };
if (file.size > 5 * 1024 * 1024) return { error: "Arquivo muito grande" };
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await fs.writeFile(`/uploads/${file.name}`, buffer);
return { success: true };
}Acompanhamento de progresso:
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));
});
// ... configuração XHR
}
return { upload, progress };
}// File é um tipo global do navegador - nenhuma importação é necessária
const FileSchema = z.instanceof(File);
type FileType = z.infer<typeof FileSchema>; // File
// Para o lado do servidor (Node), use Buffer ou Uint8Array
const ServerFileSchema = z.instanceof(Buffer);
// Tipos do atributo accept
const MIME_TYPES = ["image/jpeg", "image/png"] as const;
type MimeType = (typeof MIME_TYPES)[number];z.instanceof(File) falha no servidor - File é uma API do navegador. Correção: Use esquemas separados para validação do cliente e do servidor. No servidor, valide a entrada FormData diretamente.
Vazamentos de memória com createObjectURL - Cada chamada aloca uma URL de blob. Correção: Chame URL.revokeObjectURL(url) quando a pré-visualização for removida ou o componente for desmontado.
<input type="file"> é incontrolável - Você não pode definir seu valor programaticamente (restrição de segurança). Correção: Use uma entrada oculta e gerencie o estado separadamente com setValue.
Arquivos grandes bloqueiam a thread principal - Ler arquivos grandes para pré-visualização pode congelar a interface. Correção: Use createObjectURL (nenhuma leitura necessária) em vez de FileReader.readAsDataURL.
HEIC/HEIF no iOS - O iOS pode enviar arquivos HEIC mesmo quando você especifica accept="image/jpeg". Correção: Inclua image/heic em sua lista de aceitação, ou converta no lado do servidor.
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
| UploadThing | Você quer um serviço de upload gerenciado com componentes React | Você precisa de controle total sobre a infraestrutura de upload |
| react-dropzone | Você precisa de uma biblioteca polida de arrastar e soltar com casos extremos tratados | Uma zona de soltar simples e personalizada é suficiente |
| URLs pré-assinadas (S3) | Você quer upload direto do navegador para o armazenamento sem proxy pelo seu servidor | Você precisa de processamento no lado do servidor antes do armazenamento |
| tus-js-client | Você precisa de uploads resumíveis para arquivos grandes | Os arquivos são pequenos e o upload é rápido |
z.instanceof(File) verifica se o valor é um objeto File do navegador em tempo de execução.refine() para adicionar verificações de tamanho e tipo: .refine(f => f.size <= 5_000_000, "Máximo 5MB")File não existe no Node.jscreateObjectURL retorna uma URL de blob instantaneamente sem ler o conteúdo do arquivoFileReader.readAsDataURL bloqueia a thread principal para arquivos grandesURL.revokeObjectURL(url) quando a pré-visualização for removida para liberar memória<div onClick={() => inputRef.current?.click()}>
Solte arquivos aqui
<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 em um loop para múltiplos arquivos com o mesmo nome de campoContent-Type; o navegador o define com o boundary automaticamentesetValue("images", files, { shouldValidate: true }) atualiza o valor do formulário e aciona imediatamente a validação ZodshouldValidate, o formulário não mostraria erros até a próxima tentativa de envioFile é uma API exclusiva do navegador e não existe no Node.jsFormData, não objetos File do seu esquema do clienteacceptaccept é uma dica, não um filtro rigoroso em navegadores móveisimage/heic em sua lista de aceitação, ou converta HEIC para JPEG no lado do servidorconst FileSchema = z.instanceof(File);
type FileType = z.infer<typeof FileSchema>; // File
// Para o lado do servidor (Node.js), use 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"> nativa é incontrolável e não pode ter seu valor definido programaticamenteController envolve a entrada e gerencia o valor através do estado do RHFonChange={(e) => onChange(e.target.files?.[0])} dentro da prop render do ControllerXMLHttpRequest com xhr.upload.addEventListener("progress", callback)e.loaded e e.total para cálculo de porcentagemfetch não suporta progresso de upload nativamenteRevisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥