Home/ Cookbook/ Forms/ File upload

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.

Forms 2 primitives · 1 block · 0 patterns ~35 min React · @corelithzw/react

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.

Huchu · New supplier

Attach documents

PDFs and images. Max 50MB.

Drop files here
or click to browse · paste an image
01 Empty — drag, click, paste
Huchu · New supplier

Attach documents

Drop to upload
tax-clearance.pdfDone
bank-letter.pdf
64%
invoice.jpgRetry
02 Mid-upload — per-file progress
Huchu · New supplier

New supplier

Name · Acme Metals Ltd
VAT · ZW 2810 7841 02
Files · 3 uploading…

User keeps typing while uploads run in the background.

03 Form stays editable

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

01

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.

Code-only step

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.

Step 1 · Upload model@corelithzw/react
02

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.

Code-only step

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.

Step 2 · File picker
03

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.

Code-only step

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.

Step 3 · Upload + retry
04

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.

Code-only step

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.

Step 4 · Resumable
05

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.

Code-only step

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.

Step 5 · Submit gate

Final composition

The whole FileUploadField, assembled. Drop zone + click + paste + per-file state + retry + resumable.

Attach documents
PDFs and images. Max 50MB.
Drop to upload
or click to browse · paste an image
tax-clearance.pdfDone
bank-letter.pdf
64%
invoice.jpgRetry
FileUploadField.tsx@corelithzw/react

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, not display: none) so screen-reader users can find it via tab if they prefer.
  • Progress is announced. Each Progress bar carries aria-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 Alert that is read once, with a labelled Button for 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.