Implémenter l’envoi de fichiers par glisser-déposer dans React
Un fichier sélectionné n’a pas encore atteint votre serveur. Créez une zone de dépôt React qui vous permet de sélectionner un fichier, de l’envoyer à un récepteur Node.js local et de vérifier si le serveur l’a enregistré. L’exemple gère aussi les sélections rejetées et les requêtes échouées, sans laisser un deuxième envoi chevaucher le premier.
react-dropzone gère la sélection,
y compris le glisser-déposer et le sélecteur de fichiers. Nous utiliserons fetch() pour le transfert et
ne renverrons un succès qu’une fois que le récepteur aura fini d’écrire le fichier.
Configurer un petit projet React
Utilisez Node.js 24.15.0 et Corepack avec Yarn 4.12.0 pour ce tutoriel. L’exemple a été testé sous Linux avec React et React DOM 19.3.0, react-dropzone 20.1.2, esbuild 0.28.2, TypeScript 6.0.3 et Chromium 145. Il s’agit d’un projet d’apprentissage local, sans comptes ni stockage cloud.
Collez ceci dans Bash depuis le répertoire où vous voulez créer le projet. Les parenthèses
maintiennent votre shell dans ce répertoire parent. Si react-dropzone-demo existe déjà, choisissez un nouveau
répertoire parent ; le bloc refuse délibérément d’écraser un projet existant.
(
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
)
Enregistrez tous les fichiers ci-dessous dans react-dropzone-demo. Créez tsconfig.json pour que le compilateur
et le bundler utilisent tous deux les paramètres de ce projet :
{
"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"]
}
Sélectionner un fichier et signaler le résultat de l’envoi
Enregistrez ceci sous src/DragAndDropUpload.tsx. La zone de dépôt accepte un seul fichier PNG, JPEG, GIF, PDF ou
texte, jusqu’à 5 MiB. Une sélection invalide efface la sélection précédente. Un dépôt contenant à la
fois des fichiers acceptés et rejetés est rejeté en bloc, de sorte qu’un clic sur le bouton d’envoi
ne peut pas en envoyer silencieusement une partie seulement.
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>
)
}
L’état désactive les contrôles de sélection et d’envoi pendant que la requête est en cours. La ref bloque aussi les appels répétés rapides avant que React n’ait affiché cet état désactivé. Les requêtes échouées conservent le fichier pour Retry upload ; un enregistrement confirmé l’efface.
Ne définissez pas vous-même le Content-Type de la requête : le navigateur fournit la délimitation
multipart lors de l’envoi de FormData.
Vérifiez aussi response.ok : fetch se résout pour les réponses d’erreur HTTP,
y compris le rejet par un serveur. L’expiration du délai de 30 secondes signifie que le client n’a
pas pu confirmer le résultat ; le serveur a peut-être déjà enregistré le fichier. Une nouvelle
tentative peut donc créer une autre copie.
Ajouter un récepteur local
Enregistrez server.ts à la racine du projet. Il sert la page et accepte un seul champ multipart
nommé file sur /api/upload, de sorte que le navigateur et le récepteur partagent la même
origine. Chaque requête réussie écrit un nouveau fichier nommé d’après un UUID dans uploads/ et
renvoie HTTP 201. Le terminal affiche le chemin enregistré.
Les fichiers existants restent en place, y compris après l’arrêt du serveur ; supprimez les fichiers
envoyés par la démo lorsque vous n’en avez plus besoin.
Ce récepteur est destiné aux tests locaux. Il vérifie le nombre de champs, la taille et le type MIME déclaré, mais ni le filtre du sélecteur ni les métadonnées MIME ne prouvent ce que contiennent les octets. Il met chaque requête en mémoire tampon, avec une limite de 6 MiB par requête pour le fichier et la surcharge multipart. Un récepteur déployé nécessite une validation du contenu, une authentification, des quotas de stockage et un analyseur multipart en streaming adapté à ses 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
})
Le port zéro permet à Node de choisir un port inutilisé. Le serveur n’écoute que sur l’adresse de bouclage de cette machine. Ses vérifications au démarrage lisent les ressources compilées avant d’afficher une URL, de sorte qu’une compilation manquante provoque un échec immédiat.
Rendre les contrôles bien visibles
Enregistrez public/app.css. Conservez le contour de focus : il indique aux utilisateurs du clavier où
ils se trouvent. Les noms de fichiers longs passent à la ligne au lieu d’élargir la 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; }
Monter le composant et l’exécuter
Enregistrez 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>)
Enregistrez public/index.html. Il charge la feuille de style ci-dessus et le bundle JavaScript que nous
allons compiler ensuite :
<!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>
Depuis le même répertoire parent que la commande de configuration, vérifiez les types, créez le bundle et démarrez le récepteur :
(
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
)
Ouvrez l’URL affichée dans le terminal. Sélectionnez un fichier et cliquez sur Upload.
La page affiche d’abord Uploading…, puis
Saved on the server. après la réponse du récepteur. Comparez le
fichier dans uploads/ avec votre original ; le nom de fichier généré change, mais ses octets
devraient être identiques.
Arrêtez le serveur avec Ctrl+C. Relancez ce bloc de compilation et de démarrage après avoir modifié
les fichiers ; il n’y a pas de rechargement automatique. Une compilation échouée arrête le bloc avant
qu’il ne puisse servir un ancien bundle.
Vérifier la sélection, le rejet et la nouvelle tentative après un échec
Atteignez Choose a file avec la touche Tab et appuyez sur Entrée ou Espace pour ouvrir le sélecteur. Les props racine fournissent la gestion du clavier ; les props d’input connectent l’élément input de fichier natif. Les régions d’état et d’alerte exposent les retours sans déplacer le focus. Un clic ou un appui ouvre aussi le sélecteur, le glisser est donc facultatif. Testez avec vos navigateurs mobiles cibles et vos technologies d’assistance avant la mise en production ; les vérifications navigateur de ce tutoriel couvrent Chromium sur ordinateur.
Essayez ces scénarios d’échec en plus d’un petit envoi réussi :
- Déposez un fichier de plus de 5 MiB, un type non pris en charge ou deux fichiers ensemble. La page explique la règle de sélection et désactive Upload.
- Pendant qu’une requête est en cours, essayez de sélectionner un autre fichier ou d’envoyer à nouveau. Le fichier actuel reste sélectionné et les contrôles restent désactivés jusqu’à la fin de la requête.
- Arrêtez le serveur après avoir sélectionné un fichier, puis cliquez sur Upload.
Vous devriez voir Upload unconfirmed., avec le fichier conservé.
Redémarrez le serveur sur le même port en remplaçant
node server.tsdans le bloc d’exécution parnode server.ts PORT, avec le numéro de port de l’URL précédente. Choisissez ensuite Retry upload. Si ce port est occupé, le démarrage échoue au lieu de passer silencieusement à une autre origine.
Choisir Uppy si vous avez besoin de transferts reprenables
Cet exemple envoie une seule requête multipart ordinaire. Il ne reprend pas les transferts
interrompus et n’affiche pas de pourcentage envoyé. Pour un outil d’envoi plus complet, commencez par
l’intégration React d’Uppy
et choisissez un plugin d’envoi. Le plugin Tus d’Uppy ajoute les transferts
reprenables et nécessite un serveur tus compatible ; le récepteur /api/upload de cet exemple
n’implémente pas ce protocole.
