Implementar upload de arquivos com arrastar e soltar em React
Um arquivo selecionado ainda não chegou ao seu servidor. Crie uma área de arrastar e soltar em React que permita selecionar um arquivo, fazer upload dele para um receptor Node.js local e ver se o servidor o salvou. O exemplo também trata seleções rejeitadas e requisições com falha sem permitir que um segundo upload se sobreponha ao primeiro.
O react-dropzone cuida da seleção,
incluindo arrastar e soltar e o seletor de arquivos. Usaremos fetch() para a
transferência e retornaremos sucesso somente depois que o receptor terminar de gravar o arquivo.
Configurar um pequeno projeto React
Use Node.js 24.15.0 e Corepack com Yarn 4.12.0 neste tutorial. O exemplo foi testado no Linux com React e React DOM 19.3.0, react-dropzone 20.1.2, esbuild 0.28.2, TypeScript 6.0.3 e Chromium 145. É um projeto local de aprendizado, sem contas nem armazenamento em nuvem.
Cole este bloco no Bash a partir do diretório em que você quer criar o projeto. Os parênteses mantêm
seu shell nesse diretório pai. Se react-dropzone-demo já existir, escolha um novo diretório
pai; o bloco se recusa deliberadamente a sobrescrever um projeto existente.
(
set -e
mkdir react-dropzone-demo
cd react-dropzone-demo
printf '%s\n' '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}' > package.json
printf 'nodeLinker: node-modules\nenableGlobalCache: false\n' > .yarnrc.yml
touch yarn.lock
mkdir src public
corepack yarn add --exact react@19.3.0 react-dom@19.3.0 react-dropzone@20.1.2 \
esbuild@0.28.2 typescript@6.0.3 @types/react@19.3.0 @types/react-dom@19.3.0
)
Salve todos os arquivos abaixo em react-dropzone-demo. Crie
tsconfig.json para que tanto o compilador quanto o empacotador usem as configurações
deste projeto:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"types": ["react", "react-dom"]
},
"include": ["src/**/*.tsx"]
}
Selecionar um arquivo e informar o resultado do upload
Salve este conteúdo como src/DragAndDropUpload.tsx. A área de arrastar e soltar aceita um
arquivo PNG, JPEG, GIF, PDF ou de texto, de até 5 MiB. Uma seleção inválida limpa a seleção anterior.
Se você soltar arquivos aceitos e rejeitados juntos, o conjunto inteiro será rejeitado, para que
clicar em upload não envie silenciosamente apenas parte dele.
import { useId, useRef, useState, type ReactNode } from 'react'
import { useDropzone } from 'react-dropzone'
const accept = {
'image/png': ['.png'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/gif': ['.gif'],
'application/pdf': ['.pdf'],
'text/plain': ['.txt'],
}
export function DragAndDropUpload(): ReactNode {
const hintId = useId()
const [file, setFile] = useState<File | null>(null)
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState('Choose a file to begin.')
const [error, setError] = useState('')
const uploading = useRef(false)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept,
multiple: false,
maxSize: 5 * 1024 * 1024,
disabled: busy,
onDrop(accepted, rejected) {
if (uploading.current) return
setError('')
setStatus('Choose a file to begin.')
if (rejected.length > 0 || accepted.length !== 1) {
setFile(null)
setError('Choose one PNG, JPEG, GIF, PDF, or text file no larger than 5 MiB.')
return
}
setFile(accepted[0])
setStatus('Ready to upload.')
},
})
async function upload(): Promise<void> {
if (!file || uploading.current) return
uploading.current = true
setBusy(true)
setError('')
setStatus('Uploading…')
try {
const body = new FormData()
body.append('file', file)
const response = await fetch('/api/upload', {
method: 'POST',
body,
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) {
setStatus('Upload rejected.')
setError(`The server rejected the upload (HTTP ${response.status}). Fix the cause, then retry.`)
return
}
setStatus('Saved on the server.')
setFile(null)
} catch {
setStatus('Upload unconfirmed.')
setError('Could not confirm the upload. Check the connection and server, then retry.')
} finally {
uploading.current = false
setBusy(false)
}
}
return (
<section aria-label="File upload">
<div {...getRootProps({
role: 'button',
'aria-label': 'Choose a file',
'aria-describedby': hintId,
className: isDragActive ? 'dropzone active' : 'dropzone',
})}>
<input {...getInputProps({ 'aria-label': 'Upload file', disabled: busy })} />
<p>{isDragActive ? 'Drop the file here.' : 'Drop a file here, or click to choose.'}</p>
</div>
<p id={hintId}>One PNG, JPEG, GIF, PDF, or text file. Maximum 5 MiB.</p>
<p>{file ? `Selected: ${file.name}` : 'No file selected.'}</p>
<button type="button" onClick={upload} disabled={busy || file === null}>
{busy ? 'Uploading…' : error && file ? 'Retry upload' : 'Upload'}
</button>
<p role="status">{status}</p>
{error ? <p role="alert">{error}</p> : null}
</section>
)
}
O estado desabilita os controles de seleção e upload enquanto a requisição está pendente. A ref também bloqueia chamadas repetidas rápidas antes que o React renderize esse estado desabilitado. Requisições com falha mantêm o arquivo disponível para Retry upload; após a confirmação da gravação, a seleção atual do arquivo é limpa.
Não defina Content-Type da requisição por conta própria: o navegador fornece o
delimitador multipart ao enviar FormData.
Verifique também response.ok:
fetch resolve mesmo com respostas de erro HTTP,
inclusive uma rejeição do servidor. O tempo limite de 30 segundos significa que o cliente não
conseguiu confirmar o resultado; o servidor pode já ter salvado o arquivo. Por isso, uma nova
tentativa pode criar outra cópia.
Adicionar um receptor local
Salve server.ts na raiz do projeto. Ele serve a página e aceita um campo
multipart chamado file em /api/upload, para que o
navegador e o receptor compartilhem a mesma origem. Cada requisição bem-sucedida grava um novo
arquivo com um UUID como nome em uploads/ e retorna HTTP 201. O terminal
exibe o caminho salvo. Os arquivos existentes permanecem no local, inclusive depois que o servidor
é parado; exclua os uploads da demonstração quando não precisar mais deles.
Este receptor é para testes locais. Ele verifica a quantidade de campos, o tamanho e o tipo MIME declarado, mas nem o filtro do seletor nem os metadados MIME comprovam o que os bytes contêm. Ele armazena cada requisição em buffer, com um limite de 6 MiB por requisição para o arquivo mais a sobrecarga multipart. Um receptor em produção precisa de validação de conteúdo, autenticação, cotas de armazenamento e um parser multipart que processe os dados em fluxo, adequado aos seus limites.
import { randomUUID } from 'node:crypto'
import { once } from 'node:events'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { join } from 'node:path'
const allowedTypes = new Set([
'image/png', 'image/jpeg', 'image/gif', 'application/pdf', 'text/plain',
])
const uploads = join(import.meta.dirname, 'uploads')
const assets = new Map<string, { body: Buffer; type: string }>()
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
const asset = assets.get(request.url ?? '')
if (request.method === 'GET' && asset) {
response.writeHead(200, { 'Content-Type': asset.type }).end(asset.body)
return
}
if (request.method !== 'POST' || request.url !== '/api/upload') {
response.writeHead(404).end()
return
}
const chunks: Buffer[] = []
let bytes = 0
for await (const chunk of request) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
bytes += chunk.length
if (bytes > 6 * 1024 * 1024) {
response.writeHead(413).end()
return
}
chunks.push(chunk)
}
let form: FormData
try {
form = await new Response(Buffer.concat(chunks), {
headers: { 'Content-Type': request.headers['content-type'] ?? '' },
}).formData()
} catch {
response.writeHead(400).end()
return
}
const entries = [...form.entries()]
const file = form.get('file')
if (entries.length !== 1 || !(file instanceof File)) {
response.writeHead(400).end()
return
}
if (file.size > 5 * 1024 * 1024) {
response.writeHead(413).end()
return
}
if (!allowedTypes.has(file.type)) {
response.writeHead(415).end()
return
}
const id = randomUUID()
await writeFile(join(uploads, id), Buffer.from(await file.arrayBuffer()), { flag: 'wx' })
console.log(`Saved uploads/${id}`)
response.writeHead(201).end()
}
async function main(): Promise<void> {
const port = Number(process.argv[2] ?? 0)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
for (const [route, name, type] of [
['/', 'index.html', 'text/html; charset=utf-8'],
['/app.js', 'app.js', 'text/javascript; charset=utf-8'],
['/app.css', 'app.css', 'text/css; charset=utf-8'],
]) {
assets.set(route, { body: await readFile(join(import.meta.dirname, 'public', name)), type })
}
await mkdir(uploads, { recursive: true })
const server = createServer((request, response) => {
void handle(request, response).catch(() => {
console.error('Request failed; check that the uploads directory is writable.')
if (!response.headersSent) response.writeHead(500)
response.end()
})
})
server.listen(port, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing server address')
console.log(`Open http://127.0.0.1:${address.port}/`)
}
main().catch((error: unknown) => {
console.error('Server could not start:', error)
process.exitCode = 1
})
A porta zero permite que o Node escolha uma porta livre. O servidor escuta apenas no endereço de loopback desta máquina. Suas verificações de inicialização leem os recursos gerados pelo build antes de exibir uma URL, para que a ausência de um build cause uma falha imediata.
Tornar os controles fáceis de visualizar
Salve public/app.css. Mantenha o contorno de foco: ele mostra a quem usa o teclado
onde está o foco. Nomes de arquivo longos quebram a linha em vez de alargar a página.
body { font: 1rem/1.5 system-ui, sans-serif; margin: 0; color: #17202a; background: #fff; }
main { max-width: 36rem; margin: 2rem auto; padding: 1rem; overflow-wrap: anywhere; }
.dropzone { border: 2px dashed #2367a1; border-radius: 0.5rem; padding: 1.5rem; cursor: pointer; }
.dropzone.active { background: #e7f2ff; }
.dropzone:focus-visible, button:focus-visible { outline: 3px solid #17202a; outline-offset: 4px; }
.dropzone[aria-disabled="true"], button:disabled { cursor: not-allowed; opacity: 0.6; }
button { font: inherit; padding: 0.5rem 1rem; }
[role="alert"] { color: #9b1c1c; }
Montar o componente e executá-lo
Salve src/main.tsx:
import { createRoot } from 'react-dom/client'
import { DragAndDropUpload } from './DragAndDropUpload.tsx'
const root = document.getElementById('root')
if (!root) throw new Error('Missing root element')
createRoot(root).render(<main><h1>Upload a file</h1><DragAndDropUpload /></main>)
Salve public/index.html. Ele carrega a folha de estilos acima e o pacote JavaScript
que geraremos a seguir:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>React file upload</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/app.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/app.js"></script>
</body>
</html>
A partir do mesmo diretório pai usado no comando de configuração, verifique os tipos, gere o pacote e inicie o receptor:
(
cd react-dropzone-demo &&
corepack yarn exec tsc --project tsconfig.json &&
corepack yarn exec esbuild src/main.tsx --bundle --format=esm --jsx=automatic \
--tsconfig=tsconfig.json --outfile=public/app.js &&
node server.ts
)
Abra a URL exibida no terminal. Selecione um arquivo e clique em
Upload.
A página mostra primeiro Uploading… e depois
Saved on the server. após a resposta do receptor. Compare o
arquivo em uploads/ com o original; o nome gerado muda, mas os bytes devem ser
iguais. Pare o servidor com Ctrl+C. Execute novamente este bloco de build e inicialização depois
de editar os arquivos; não há recarregamento automático. Uma falha no build interrompe o bloco
antes que ele possa servir um pacote antigo.
Verificar seleção, rejeição e recuperação
Use Tab para chegar a Choose a file e pressione Enter ou Espaço para abrir o seletor. As props do elemento raiz fornecem o tratamento de teclado; as props do input conectam o campo nativo de seleção de arquivo. As regiões de status e alerta comunicam o resultado sem mover o foco. Um clique ou toque também abre o seletor, então arrastar é opcional. Teste com os navegadores móveis e as tecnologias assistivas do seu público-alvo antes de disponibilizar a aplicação; as verificações de navegador deste tutorial cobrem o Chromium para desktop.
Teste estes cenários de falha, além de um upload bem-sucedido de um arquivo pequeno:
- Solte um arquivo maior que 5 MiB, de um tipo não aceito ou dois arquivos juntos. A página explica a regra de seleção e desabilita Upload.
- Enquanto uma requisição estiver pendente, tente selecionar outro arquivo ou fazer upload novamente. O arquivo atual continua selecionado e os controles permanecem desabilitados até que a requisição termine.
- Pare o servidor depois de selecionar um arquivo e clique em
Upload.
Você deverá ver Upload unconfirmed., com o arquivo mantido.
Reinicie o servidor na mesma porta substituindo
node server.tsno bloco de execução pornode server.ts PORT, usando o número da porta da URL anterior. Em seguida, escolha Retry upload. Se essa porta estiver ocupada, a inicialização falhará em vez de mudar silenciosamente para uma origem diferente.
Escolher o Uppy quando precisar de transferências retomáveis
Este exemplo envia uma única requisição multipart comum. Ele não retoma transferências
interrompidas nem mostra a porcentagem do upload concluída. Para um componente de upload mais
completo, comece pela integração do Uppy com React
e escolha um plugin de upload. O plugin Tus do Uppy adiciona
transferências retomáveis e precisa de um servidor tus compatível; o receptor
/api/upload deste exemplo não implementa esse protocolo.
