Subida de archivos en React mediante arrastrar y soltar
Un archivo seleccionado aún no ha llegado a tu servidor. Crea una zona de arrastre en React que te permita seleccionar un archivo, subirlo a un receptor local Node.js y ver si el servidor lo guardó. El ejemplo también maneja selecciones rechazadas y solicitudes fallidas sin permitir que una segunda subida se superponga con la primera.
react-dropzone gestiona la selección,
incluidos el arrastre y el selector de archivos. Usaremos fetch() para la
transferencia y solo devolveremos un resultado de éxito cuando el receptor termine de escribir el
archivo.
Configura un pequeño proyecto React
Usa Node.js 24.15.0 y Corepack con Yarn 4.12.0 para seguir este tutorial. El ejemplo se probó en Linux con React y React DOM 19.3.0, react-dropzone 20.1.2, esbuild 0.28.2, TypeScript 6.0.3 y Chromium 145. Es un proyecto local de aprendizaje, sin cuentas ni almacenamiento en la nube.
Pega esto en Bash desde el directorio donde quieras crear el proyecto. Los paréntesis mantienen
el shell en ese directorio padre. Si react-dropzone-demo ya existe, elige un nuevo
directorio padre; el bloque se niega deliberadamente a sobrescribir un proyecto 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
)
Guarda todos los archivos siguientes dentro de react-dropzone-demo.
Crea tsconfig.json para que tanto el compilador como el empaquetador usen la
configuración de este proyecto:
{
"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"]
}
Selecciona un archivo e informa del resultado de la subida
Guarda esto como src/DragAndDropUpload.tsx. La zona de arrastre acepta un archivo PNG, JPEG,
GIF, PDF o de texto de hasta 5 MiB. Una selección no válida borra la selección anterior. Si se
sueltan archivos aceptados y rechazados a la vez, se rechaza todo el conjunto, de modo que hacer
clic en el botón de subida no pueda enviar solo una parte sin avisar.
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>
)
}
El estado deshabilita los controles de selección y subida mientras la solicitud está pendiente. La referencia también bloquea las llamadas repetidas rápidas antes de que React haya renderizado ese estado deshabilitado. Las solicitudes fallidas mantienen el archivo disponible para Retry upload; tras confirmarse el guardado, se borra la selección actual.
No establezcas manualmente Content-Type de la solicitud: el navegador proporciona
el delimitador multipart al enviar FormData.
Comprueba también response.ok:
fetch resuelve su promesa ante respuestas de error HTTP,
incluido un rechazo del servidor. El tiempo de espera de 30 segundos significa que el cliente no
pudo confirmar el resultado; es posible que el servidor ya haya guardado el archivo. Por lo tanto,
un reintento puede crear otra copia.
Añade un receptor local
Guarda server.ts en la raíz del proyecto. Sirve la página y acepta un campo
multipart llamado file en /api/upload, de modo que el
navegador y el receptor comparten un origen. Cada solicitud exitosa escribe un archivo nuevo cuyo
nombre es un UUID en uploads/ y devuelve HTTP 201. La terminal muestra la
ruta del archivo guardado. Los archivos existentes permanecen en su lugar, incluso después de
detener el servidor; elimina los archivos subidos del ejemplo cuando ya no los necesites.
Este receptor es para pruebas locales. Comprueba la cantidad de campos, el tamaño y el tipo MIME declarado, pero ni el filtro del selector ni los metadatos MIME demuestran qué contienen los bytes. Almacena cada solicitud en un búfer, con un límite de 6 MiB por solicitud para el archivo más los datos adicionales de multipart. Un receptor desplegado necesita validación de contenido, autenticación, cuotas de almacenamiento y un analizador multipart en streaming adecuado a sus límites.
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
})
El puerto cero permite a Node elegir un puerto libre. El servidor solo escucha en la dirección de loopback de esta máquina. Sus comprobaciones de inicio leen los archivos generados en la compilación antes de mostrar una URL, por lo que el inicio falla inmediatamente si falta la compilación.
Haz que los controles sean fáciles de ver
Guarda public/app.css. Conserva el contorno de enfoque: muestra a quienes usan el
teclado dónde se encuentran. Los nombres de archivo largos se ajustan en varias líneas en lugar de
ensanchar la 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; }
Monta el componente y ejecútalo
Guarda 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>)
Guarda public/index.html. Carga la hoja de estilos anterior y el paquete JavaScript que
compilaremos a continuación:
<!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>
Desde el mismo directorio padre que usaste para el comando de configuración, comprueba los tipos, empaqueta el código e inicia el 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
)
Abre la URL que aparece en la terminal. Selecciona un archivo y haz clic en
Upload.
La página primero muestra Uploading… y luego
Saved on the server. tras la respuesta del receptor.
Compara el archivo de uploads/ con el original; el nombre generado cambia,
pero sus bytes deberían coincidir. Detén el servidor con Ctrl+C. Vuelve a ejecutar este bloque de
compilación e inicio después de editar los archivos; no hay recarga automática. Una compilación
fallida detiene el bloque antes de que pueda servir un paquete anterior.
Comprueba la selección, el rechazo y la recuperación
Usa Tab para llegar a Choose a file y pulsa Enter o la barra espaciadora para abrir el selector. Las props del elemento raíz proporcionan el manejo del teclado; las props del campo de entrada conectan el campo nativo de selección de archivos. Las regiones de estado y alerta comunican los resultados sin mover el enfoque. Un clic o un toque también abre el selector, por lo que arrastrar es opcional. Haz pruebas con los navegadores móviles y las tecnologías de asistencia de tu público objetivo antes de publicar; las pruebas de navegador de este tutorial cubren Chromium de escritorio.
Prueba estos casos de fallo, además de una subida exitosa de un archivo pequeño:
- Suelta un archivo de más de 5 MiB, uno de un tipo no admitido o dos archivos juntos. La página explica la regla de selección y deshabilita Upload.
- Mientras una solicitud esté pendiente, intenta seleccionar otro archivo o subirlo de nuevo. El archivo actual permanece seleccionado y los controles siguen deshabilitados hasta que finaliza la solicitud.
- Detén el servidor después de seleccionar un archivo y luego haz clic en
Upload.
Deberías ver Upload unconfirmed., y el archivo se conserva.
Reinicia el servidor en el mismo puerto reemplazando
node server.tsen el bloque de ejecución pornode server.ts PORT, con el número de puerto de la URL anterior. Luego elige Retry upload. Si ese puerto está ocupado, el inicio falla en lugar de cambiar a otro origen sin avisar.
Elige Uppy cuando necesites transferencias reanudables
Este ejemplo envía una solicitud multipart común. No reanuda transferencias interrumpidas ni
muestra el porcentaje subido. Para un componente de subida más completo, empieza por la
integración de Uppy con React y elige un plugin de subida.
El plugin Tus de Uppy añade transferencias reanudables y necesita
un servidor compatible con el protocolo tus; el receptor /api/upload de este
ejemplo no implementa ese protocolo.
