Implementing drag and drop file upload in React
A selected file has not reached your server yet. Build a React dropzone that lets you select one file, upload it to a local Node.js receiver, and see whether the server saved it. The example also handles rejected selections and failed requests without allowing a second upload to overlap the first.
react-dropzone handles selection,
including drag and drop and the file picker. We will use fetch() for the transfer and return success
only after the receiver finishes writing the file.
Set up a small React project
Use Node.js 24.15.0 and Corepack with Yarn 4.12.0 for this walkthrough. The example was tested on Linux with React and React DOM 19.3.0, react-dropzone 20.1.2, esbuild 0.28.2, TypeScript 6.0.3, and Chromium 145. It is a local learning project, with no accounts or cloud storage.
Paste this into Bash from the directory where you want to create the project. The parentheses keep
your shell in that parent directory. If react-dropzone-demo already exists, choose a fresh parent
directory; the block deliberately refuses to overwrite an existing project.
(
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
)
Save all the files below inside react-dropzone-demo. Create tsconfig.json so both the compiler
and bundler use this project's settings:
{
"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"]
}
Select a file and report the upload result
Save this as src/DragAndDropUpload.tsx. The dropzone accepts one PNG, JPEG, GIF, PDF, or text file,
up to 5 MiB. An invalid selection clears the previous selection. A drop containing both accepted
and rejected files is rejected as a whole, so clicking upload cannot silently send just part of it.
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>
)
}
The state disables selection and upload controls while the request is pending. The ref also blocks rapid repeated calls before React has rendered that disabled state. Failed requests keep the file available for Retry upload; a confirmed save clears it.
Do not set the request's Content-Type yourself: the browser supplies the multipart boundary when
sending FormData.
Also check response.ok: fetch resolves for HTTP error responses,
including a server's rejection. The 30-second timeout means the client could not confirm the result;
the server may already have saved the file. A retry can therefore create another copy.
Add a local receiver
Save server.ts in the project root. It serves the page and accepts one multipart field named
file at /api/upload, so the browser and receiver share an origin. Each successful request writes
a new UUID-named file under uploads/ and returns HTTP 201. The terminal prints the saved path.
Existing files stay in place, including after stopping the server; delete the demo's uploads when
you no longer need them.
This receiver is for local testing. It checks field count, size, and the declared MIME type, but neither the picker filter nor MIME metadata proves what the bytes contain. It buffers each request, with a 6 MiB request limit for the file plus multipart overhead. A deployed receiver needs content validation, authentication, storage quotas, and a streaming multipart parser appropriate to its limits.
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
})
Port zero lets Node choose an unused port. The server binds only to this machine's loopback address. Its startup checks read the built assets before printing a URL, so a missing build fails immediately.
Make the controls easy to see
Save public/app.css. Keep the focus outline: it shows keyboard users where they are. Long filenames
wrap instead of widening the page.
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; }
Mount the component and run it
Save 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>)
Save public/index.html. It loads the stylesheet above and the JavaScript bundle we will build next:
<!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>
From the same parent directory as the setup command, type-check, bundle, and start the receiver:
(
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
)
Open the URL printed in the terminal. Select a file and click Upload.
The page first shows Uploading…, then
Saved on the server. after the receiver's response. Compare the
file in uploads/ with your original; the generated filename changes, but its bytes should match.
Stop the server with Ctrl+C. Re-run this build-and-start block after editing the files; there is no
automatic reload. A failed build stops the block before it can serve an older bundle.
Check selection, rejection, and recovery
Tab to Choose a file and press Enter or Space to open the picker. The root props provide the keyboard handling; the input props connect the native file input. The status and alert regions expose feedback without moving focus. A click or tap opens the picker too, so dragging is optional. Test with your target mobile browsers and assistive technology before shipping; this walkthrough's browser checks cover desktop Chromium.
Try these failure paths as well as a small successful upload:
- Drop a file larger than 5 MiB, an unsupported type, or two files together. The page explains the selection rule and disables Upload.
- While a request is pending, try selecting another file or uploading again. The current file stays selected and the controls remain disabled until the request finishes.
- Stop the server after selecting a file, then click Upload.
You should see Upload unconfirmed., with the file retained.
Restart the server on the same port by replacing
node server.tsin the run block withnode server.ts PORT, using the previous URL's port number. Then choose Retry upload. If that port is occupied, startup fails rather than silently moving to a different origin.
Choose Uppy when you need resumable transfers
This example sends one ordinary multipart request. It does not resume interrupted transfers or
show a percentage uploaded. For a larger uploader, start with Uppy's React integration
and choose an upload plugin. Uppy's Tus plugin adds resumable transfers
and needs a compatible tus server; this example's /api/upload receiver does not implement that
protocol.
