Build a file-upload field that doesn't block the form
By the end of this recipe you'll have a drag-drop zone with click-to-browse and paste-image fallback, per-file progress, automatic retry on failure, resumable chunked upload for files over 5MB, and uploads that run in the background so the user keeps typing into the rest of the form.
Overview
A field that accepts files from drag, click, or clipboard paste. Each file gets its own progress, its own retry, and — when large — its own resumable chunked upload. The user keeps interacting with the rest of the form while files upload.
Most upload UIs are still blocking: the form sits there with a spinner until every byte is on the server. The fix is to treat upload as a background task, like an autosave. The user picks a file, the upload starts, a progress chip appears, and they can keep filling fields. Submit waits for in-flight uploads to settle — and surfaces a clear "still uploading 2 files" if the user tries to send too early.
Always upload in the background — never block the form. The user's attention is on what they're typing, not on the bytes leaving their browser. A blocked form punishes the careful user; a background upload rewards them.
Resumable uploads kick in over 5MB. Below that, the round-trip on a flaky connection is faster than the chunk-coordination overhead. Above it, a dropped connection should pick up where it stopped — nobody wants to re-upload a 40MB PDF because they walked between two access points.
Skip this recipe if your app uploads small files only (under 1MB) on a server you control. A plain <input type="file"> + a fetch will do; you don't need chunks or retry.
What you'll build
Empty drop zone, mid-upload with one file failed and one resuming, and the form's other fields stay editable.
Attach documents
PDFs and images. Max 50MB.
Attach documents
New supplier
User keeps typing while uploads run in the background.
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: Stack, FileUpload, useUpload, Progress, Button, Alert. useUpload owns the per-file state and the chunk coordinator.
Step-by-step build
Model each file as a state machine
Every picked file lives in one of a handful of states. Branch the renderer per state, not per boolean. 'failed' carries the retry handle; 'uploading' carries progress; 'done' carries the server-side id.
Per-file state. No global isLoading boolean.
Client rejects malware-y extensions. Server re-checks. Both belong.
Switch to the Code tab to see the snippet.
Accept files from drop, click, and clipboard paste
The drop zone is one component; the input is hidden behind it. onPaste on the document listens for image clipboard data. The same accept handler runs for all three sources, so behaviour is identical no matter how the file arrived.
Paste listens at the window level — works no matter where focus is.
Input is reset after each pick so the same file can be re-picked.
Switch to the Code tab to see the snippet.
Upload each file independently — with retry built in
Each file owns its own XMLHttpRequest so progress is per-file. onerror moves it to 'failed' with a retry handle that re-runs the same upload from scratch. The hook never re-renders the rest of the form when one file's progress changes — uploads live in their own state slice.
Files over 5MB go through the resumable path.
Retry is a closure on the original file — same upload, fresh XHR.
Switch to the Code tab to see the snippet.
Add the resumable path for files over 5MB
The server gives us a session URL on first PUT, then we POST chunks of 1MB each with byte-range headers. A dropped connection leaves the session alive on the server; resume re-asks the server "what byte are you at?" and continues from there.
A connection drop just rewinds to the last accepted byte.
Retry re-opens the session URL — same flow, fresh state.
Switch to the Code tab to see the snippet.
Block the submit on in-flight uploads — clearly
The form's submit is allowed to fire any time. If uploads are still running, the submit handler counts them and surfaces a "still uploading 2 files" alert; it does not silently wait. Users hate silent waits more than they hate clear ones.
Never a silent wait. Tell the user what's blocking.
The form's other fields keep being editable the whole time.
Switch to the Code tab to see the snippet.
Final composition
The whole FileUploadField, assembled. Drop zone + click + paste + per-file state + retry + resumable.
Variations
Three common forks. Each is a small diff from the final composition.
Single file only
A profile photo or signed document. Cap to one upload and replace any prior file.
const add = useCallback((files: File[]) => {
const file = files[0];
if (!file) return;
// replace existing
uploads.forEach(u => remove(u.id));
setUploads([toUpload(file)]);
}, [uploads]);
Image-only with preview
Camera roll uploads. Filter by MIME and render an inline thumbnail per file.
const accept = (file: File) =>
file.type.startsWith('image/');
{u.file.type.startsWith('image/') && (
<img
src={URL.createObjectURL(u.file)}
alt={u.name}
/>
)}
Direct-to-S3 with presigned URL
Skip your server. Server hands out a presigned PUT URL; the browser uploads straight to S3.
// Open session = ask for a presigned URL
const { putUrl, publicUrl } =
await fetch('/api/sign')
.then(r => r.json());
await fetch(putUrl, {
method: 'PUT', body: file,
headers: { 'content-type': file.type },
});
Accessibility
What this recipe takes care of for you.
- The drop zone is keyboard-actionable.
role="button"+tabIndex=0+ Enter/Space handler — drag-drop is a sighted-mouse feature, but the same surface opens the file picker via the keyboard. - The hidden input is reachable too. The
<input type="file">stays in the DOM (hidden, notdisplay: none) so screen-reader users can find it via tab if they prefer. - Progress is announced. Each
Progressbar carriesaria-label="Uploading bank-letter.pdf"+aria-valuenow. Screen readers re-announce on milestone changes (10%, 50%, 90%). - Per-file errors are role="alert". A failed upload renders an inline
Alertthat is read once, with a labelledButtonfor retry. - Rejected files are explained. "exe files aren't allowed" is rendered inline next to the file, not hidden in a toast — it's a permanent state, not a passing event.
- The submit gate names the problem. "2 files are still uploading. Try again when they finish." — never a silent disabled button.
- Paste works without focus on the field. The paste listener is global, so screen-reader users who Ctrl+V from the clipboard get the same outcome as a sighted user pasting into the dropzone.