Files and Uploads
Learn to work with the browser File and Blob APIs inside React — reading files chosen via an input or dropped onto the page, previewing them, validating them, uploading them with progress tracking, and avoiding the security pitfalls of handling user-supplied files.
Any feature that lets a user attach a photo, submit a resume, or import a CSV touches the browser's File APIs sooner or later. Files behave differently from ordinary form data: they can be large, they need to be read asynchronously, and the browser gives React no built-in component for previewing or uploading them — all of that is assembled from a handful of lower-level Web APIs (File, Blob, FormData, and object URLs) wired into component state and effects.
File, Blob, and FileList
A Blob is the browser's generic representation of raw, immutable binary data with an associated MIME type; a File is a Blob with two extra properties, name and lastModified, representing a file that came from the user's disk. Both expose the same reading methods — .text(), .arrayBuffer(), and .stream() — which all return promises (or a stream), because reading file contents is inherently asynchronous even for a file already sitting on disk.
When a user picks one or more files through an <input type="file"> element, the DOM hands back a FileList on event.target.files — an array-like object, not a real array, indexed with bracket notation and a .length property but missing methods like .map or .filter. The idiomatic fix in React code is to spread it into a real array immediately (Array.from(event.target.files)) so the rest of the component can use normal array methods and TypeScript can type it as File[].
Drag-and-drop delivers files through a different but related path: the DataTransfer object on a drop event's dataTransfer property, whose .files property is also a FileList. Handling drop requires calling event.preventDefault() on both dragover and drop — the browser's default action for a dropped file is to navigate to and display it, which would blow away the whole app.
Previews, uploads, and progress
To preview an image or PDF the user just selected, without uploading it anywhere, URL.createObjectURL(file) creates a special blob: URL that points directly at the in-memory file and can be dropped straight into an <img src> or <a href>. This URL is not automatically freed — it keeps the underlying data alive until the page navigates away or the code calls URL.revokeObjectURL(url) explicitly, so a component that creates one in an effect must revoke it in that same effect's cleanup to avoid a memory leak, especially when previews are created and discarded repeatedly (e.g. the user picks a different file).
Actually sending a file to a server is done with FormData, a container built specifically for multipart/form-data submissions. Appending a File to a FormData instance (formData.append('avatar', file)) and passing that FormData as the body of a fetch call handles all of the multipart encoding automatically — no need to set a Content-Type header manually, since fetch derives the correct boundary-delimited value from the FormData object itself.
The plain fetch API has no built-in way to report upload progress — a fetch promise resolves only once the whole request/response cycle finishes. Progress tracking (a percentage bar while a large video uploads) requires XMLHttpRequest's upload.onprogress event instead, or a fetch-based library that streams the request body and exposes progress through a ReadableStream. For most apps, XMLHttpRequest wrapped in a small promise-returning helper remains the simplest reliable option.
Validation and security
Client-side validation — checking file.size against a maximum and file.type against an allow-list before the user waits through an upload that the server will reject anyway — is purely a UX courtesy, never a security boundary. The browser's reported file.type is inferred from the file extension or a client-supplied MIME type and is trivial to spoof, so the server must independently verify file size, content type (ideally by inspecting the file's actual bytes, not trusting the header), and, for anything executable or scriptable, must never serve an uploaded file from the same origin as the app without careful isolation (a separate subdomain or storage bucket), to prevent an uploaded HTML or SVG file from executing script in the context of the main site.
Because a very large file read synchronously (or previewed at full resolution) can freeze the tab, image previews for large files are often downscaled via a canvas before being shown, and multi-megabyte files are frequently uploaded in chunks rather than as one request, both to show incremental progress and to let a failed chunk be retried without restarting the whole transfer.
Selecting, previewing, and uploading a file
revokeObjectURL runs both when the file changes and when the component unmounts, since the effect's cleanup covers both cases. XMLHttpRequest is used here specifically for upload.onprogress, which fetch does not provide.
function AvatarUploader() {
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = e.target.files?.[0] ?? null;
setFile(selected);
}
useEffect(() => {
if (!file) {
setPreviewUrl(null);
return;
}
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
function upload() {
if (!file) return;
const formData = new FormData();
formData.append("avatar", file);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.open("POST", "/api/avatar");
xhr.send(formData);
}
return (
<div>
<input type="file" accept="image/*" onChange={handleFileChange} />
{previewUrl && <img src={previewUrl} alt="Preview" width={120} />}
<button onClick={upload} disabled={!file}>Upload ({progress}%)</button>
</div>
);
}Code Examples
Both dragover and drop call preventDefault() so the browser doesn't navigate to the dropped file; validation runs client-side purely to give immediate feedback, not as a security guarantee.
Common mistake: Forgetting preventDefault() on dragover (not just drop) means the drop event never fires with the expected file data in some browsers.
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp"];
function Dropzone({ onFile }: { onFile: (file: File) => void }) {
const [error, setError] = useState<string | null>(null);
const [isOver, setIsOver] = useState(false);
function validate(file: File): string | null {
if (!ALLOWED_TYPES.includes(file.type)) return "Only PNG, JPEG, or WebP images are allowed.";
if (file.size > MAX_SIZE) return "File must be under 5MB.";
return null;
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setIsOver(false);
const file = e.dataTransfer.files[0];
if (!file) return;
const validationError = validate(file);
if (validationError) {
setError(validationError);
return;
}
setError(null);
onFile(file);
}
return (
<div
onDragOver={(e) => { e.preventDefault(); setIsOver(true); }}
onDragLeave={() => setIsOver(false)}
onDrop={handleDrop}
style={{ border: isOver ? "2px solid blue" : "2px dashed gray", padding: 24 }}
>
Drop an image here
{error && <p style={{ color: "red" }}>{error}</p>}
</div>
);
}Important Rules
Convert a FileList to a real array (Array.from) before using array methods on selected files.
Revoke any object URL created with URL.createObjectURL in the same effect's cleanup that created it.
Use FormData, not JSON.stringify, to send a File in a request body, and let fetch set the Content-Type automatically.
Treat client-side file type/size checks as UX only — always re-validate on the server.
Call preventDefault() on both dragover and drop to enable custom drop handling.
Use XMLHttpRequest (or a streaming-capable library) instead of fetch when upload progress reporting is required.
Where this shows up
Profile photo uploaders, resume/document submission forms, CSV import tools, and drag-and-drop media galleries all build on this same set of APIs. Products that handle large files — video platforms, design tools with asset libraries — layer chunked uploads and resumable transfer protocols (like tus) on top of the same FormData/XMLHttpRequest foundation to survive flaky connections.
const url = URL.createObjectURL(file); setPreviewUrl(url); // url is never revoked useEffect(() => { const url = URL.createObjectURL(file); setPreviewUrl(url); return () => URL.revokeObjectURL(url); }, [file]);
An un-revoked object URL keeps the file's data resident in memory for the lifetime of the document, so repeatedly picking new files without revoking old previews leaks memory.
formData.append("file", file); fetch("/upload", { method: "POST", headers: { "Content-Type": "multipart/form-data" }, body: formData, });
formData.append("file", file); fetch("/upload", { method: "POST", body: formData });
Multipart requests need a boundary parameter in the Content-Type header that only fetch (computed from the FormData) can generate correctly; setting the header manually omits the boundary and breaks server-side parsing.
Why must Array.from() (or a spread) typically be used on event.target.files before mapping over it? A. FileList entries are strings, not File objects B. FileList is array-like but not a real array, so it lacks methods like .map and .filter C. event.target.files is always empty until converted D. TypeScript forbids iterating over FileList directly
B. FileList is array-like but not a real array, so it lacks methods like .map and .filter — FileList supports indexing and .length but not Array.prototype methods, so it's typically converted to a real array first.
[true-false] Checking file.type and file.size on the client is sufficient to prevent malicious file uploads. A. True B. False
B. False — Client-reported file type and size can be spoofed; the server must independently validate any uploaded file's size and actual content.
Which API provides a native upload-progress event that plain fetch() does not? A. URL.createObjectURL B. FormData C. XMLHttpRequest D. Blob.text()
C. XMLHttpRequest — XMLHttpRequest exposes upload.onprogress for tracking bytes sent during an upload; fetch's promise only resolves once the whole exchange completes.
Build a multi-file uploader: accept multiple files via input multiple, show a thumbnail preview and per-file progress bar for each, and let the user remove a file from the list before uploading. Hint: Store an array of { file: File; id: string; progress: number } in state; revoke each file's object URL when it is removed from the array, not just on unmount.
Try it yourself — there's no single right answer here; compare your result against this lesson's examples.