I20 · Part 2 · Intermediate
File uploads
A file input works without any of your code. Progress, drag and drop and large files are what you add.
09On this page
Start with the input that already works
<input type="file" accept="image/*" multiple onChange={(e) => add(e.target.files)} />
A native file input is keyboard accessible, works with screen readers, opens the platform's file picker (including the camera on phones), and supports paste on some browsers. Everything else in this chapter is a layer on top of it. If you build a custom drop zone and lose the input, you have made the feature harder for keyboard users to reach than the browser's own default.
e.target.files is a FileList, which is not an array.
Convert it with Array.from(e.target.files) before mapping, and
reset e.target.value = "" afterwards if you want choosing the same
file twice to fire onChange again.
What a File tells you, and what to trust
file.name // "cv final v2.pdf"
file.size // bytes
file.type // "application/pdf" — reported by the browser, from the extension
file.type and file.name are claims made by the client.
Renaming malware.exe to photo.png changes both.
Validating them in the browser is for the user's benefit — fast feedback
before a slow upload — and the server must check again, ideally by
looking at the file's actual leading bytes rather than its name.
Sending it
const body = new FormData();
body.append("file", file);
await fetch("/api/upload", { method: "POST", body }); // no Content-Type header
Do not set Content-Type yourself. A multipart body needs a
boundary string in the header, and the browser generates both together; set it
by hand and the boundary is missing and the server cannot parse the request.
Progress: the case for XMLHttpRequest
function upload(file, onProgress, signal) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
};
xhr.onload = () => (xhr.status < 300 ? resolve(xhr.response) : reject(new Error(xhr.statusText)));
xhr.onerror = () => reject(new Error("Network error"));
signal?.addEventListener("abort", () => { xhr.abort(); reject(new DOMException("Aborted", "AbortError")); });
const body = new FormData();
body.append("file", file);
xhr.send(body);
});
}
fetch has no upload progress event. The dependable way to get one
is xhr.upload, which fires progress with
loaded and total bytes. It feels old, but it is the
right tool here; wrap it once in a promise, as above, and nothing else in the
app has to know. Keep the AbortController signal, because a user
who cancels a 200 MB upload expects the bytes to stop.
One caveat: this measures bytes handed to the network, not bytes the server has stored. A bar that reaches 100% then sits there while the server processes the file feels broken, so show a separate "processing" state after it.
Preview without uploading
function Preview({ file }) {
const [url, setUrl] = useState(null);
useEffect(() => {
const objectUrl = URL.createObjectURL(file);
setUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl); // or the browser keeps the file in memory
}, [file]);
return url && <img src={url} alt={file.name} />;
}
An object URL points at the file in memory and lives until it is revoked or the page closes. Forgetting the cleanup is a real leak on a page where users add many photos. It is also a textbook case for an effect — the object URL is a resource that must be released.
Drag and drop, as an enhancement
function DropZone({ onFiles, children }) {
const inputRef = useRef(null);
const [over, setOver] = useState(false);
return (
<div
onDragOver={(e) => { e.preventDefault(); setOver(true); }} // required, or drop never fires
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault(); // or the browser opens the file
setOver(false);
onFiles(Array.from(e.dataTransfer.files));
}}
data-over={over}
>
{children}
<button type="button" onClick={() => inputRef.current.click()}>Choose files</button>
<input ref={inputRef} type="file" multiple hidden onChange={(e) => onFiles(Array.from(e.target.files))} />
</div>
);
}
Two details cause most of the bugs. Cancelling dragover is what
marks the element as a valid drop target — without
preventDefault the drop event never fires, and without it in
drop the browser navigates to the dropped file. And
dragleave fires when the pointer moves onto a child element,
so a highlight tied to it flickers; track depth with a counter, or set the
state only from dragover and clear it on leave and drop.
The button is not optional
Drag and drop cannot be used with a keyboard, a switch device, or most screen readers. The zone must always contain a real button (or a label wrapping the input) that does the same job. Announce results in a live region — "3 files added, 1 rejected: too large" — because a sighted user sees the list change and nobody else does.
Each file is its own little state machine
type UploadItem =
| { id: string; file: File; status: "queued" }
| { id: string; file: File; status: "uploading"; progress: number }
| { id: string; file: File; status: "done"; url: string }
| { id: string; file: File; status: "error"; message: string };
Keep a list of these in a reducer: one
action per transition, so a progress event for a file that was just cancelled
cannot resurrect it. Note that progress exists only on the
uploading branch — the
discriminated union makes reading it
elsewhere a compile error. Limit concurrency to three or four at a time; forty
parallel uploads saturate the connection and make every one of them slower.
Large files should skip your server
// 1. ask your server for permission to upload one specific object
const { url, key } = await fetch("/api/upload-url", {
method: "POST",
body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
}).then((r) => r.json());
// 2. send the bytes straight to storage
await upload(file, url, onProgress);
// 3. tell your server it finished, so it can record it
await fetch("/api/uploads/" + key + "/complete", { method: "POST" });
Streaming a large file through your own server ties up a request for its whole duration and runs into body limits — a Server Action, for one, defaults to a 1 MB request body. A presigned URL is the standard fix: the server validates the request and signs a short-lived URL for exactly one object in object storage, and the browser uploads to it directly. For files large enough that a dropped connection is likely, use a resumable protocol (multipart upload, or tus) that sends chunks and continues from the last one.
On the server, whatever the client did
- Limit size at the edge, before the whole body is read.
- Check the content, not the extension or the reported type.
- Generate the storage name yourself. A client filename like
../../configis a path traversal attempt, and two users will uploadimage.png. - Serve uploads from a different origin, or with
Content-Disposition: attachment, so an uploaded HTML file cannot run script on your domain. - Scan anything other users will download.
Interview answer, one sentence
"Keep the native input and layer drag and drop on top of it, use
XMLHttpRequest when I need upload progress because
fetch has none, model each file as a small state machine, send
large files straight to storage with a presigned URL, and treat the file's
name and type as untrusted on the server."
Opens in the editor: write it, run it, and check it against real tests.
All 66 for this level →Finished reading?
0 of 57 chapters read in this series