Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I20
31 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛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.

5 min read9 sections2 exercises
31 of 57

In this chapter

  • Start with the input that already works
  • What a File tells you, and what to trust
  • Sending it
  • Progress: the case for XMLHttpRequest
  • Preview without uploading

and 4 more

about 5 min
09On this page
  1. Start with the input that already works
  2. What a File tells you, and what to trust
  3. Sending it
  4. Progress: the case for XMLHttpRequest
  5. Preview without uploading
  6. Drag and drop, as an enhancement
  7. Each file is its own little state machine
  8. Large files should skip your server
  9. On the server, whatever the client did

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 ../../config is a path traversal attempt, and two users will upload image.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."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsValidate a batch of filesintermediate
  • 6 testsPreview the selected files, without touching the real inputintermediate
All 66 for this level →
I20

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I19Previous · I19Internationalisation
  2. I20you are here
  3. Next · I21Real-time UIPolling, Server-Sent Events or a WebSocket: choose the smallest thing that works, then handle the connection dropping.5 min read

01On this page

9 sections · 0%

  1. 01Start with the input that already works
  2. 02What a File tells you, and what to trust
  3. 03Sending it
  4. 04Progress: the case for XMLHttpRequest
  5. 05Preview without uploading
  6. 06Drag and drop, as an enhancement
  7. 07Each file is its own little state machine
  8. 08Large files should skip your server
  9. 09On the server, whatever the client did

[] chapters · t top