Skip to content

UI build: file uploader with progress and cancel

Check before sending, read the status when it ends, abort the right request, and never let a late event reopen a finished file.

Predict first

Before you read: predict

ExamHall asks each candidate for an ID photo before the exam. Its API stores photos up to 10 MB, reads the whole body, then answers 413 Payload Too Large to anything bigger. The photo field:

A candidate picks a 12.4 MB phone photo. Every byte goes up, then the 413 arrives.

What does the field show after that?

Code for this question
function IdPhoto() {  const [status, setStatus] = useState('Choose a photo');   function onChange(e) {    const xhr = new XMLHttpRequest();    xhr.upload.onprogress = (ev) => setStatus(`Uploading ${Math.floor((100 * ev.loaded) / ev.total)}%`);    xhr.onload = () => setStatus('Uploaded');    xhr.onerror = () => setStatus('Upload failed');    xhr.open('POST', '/api/id-documents');    xhr.send(e.target.files[0]);  }   return (    <label>      ID photo <input type="file" accept="image/jpeg" onChange={onChange} />      <output>{status}</output>    </label>  );}
Pick one

The 30-second version

Check each file's type and size before any request. Send each with its own XMLHttpRequest, upload listeners attached before send(). load means a reply came, not that it was good: read the status. Cancel calls abort() on that file's request. Give each file one state; a finished file ignores every later event.

Check before sending, read the status when it ends, abort the right request, and never let a late event reopen a finished file.

How it works

Step 1 of 12

Check before sending. On change, test each file's type and size. A failing file gets a rejected row, no request; accept only guides the picker.
In the kitchen
  • the nurse checks weight and a finger-prick haemoglobin before any needle; a donor who fails goes home with no needle in → the type and size check, no request (step 1)

Analogy: A blood donation session (transfusion services)

At a donation session, donors sit in chairs along the wall, each with a bag on a rocking scale, and a nurse watches the row.

At the donation session In the uploader
the nurse checks weight and a finger-prick haemoglobin before any needle; a donor who fails goes home with no needle in the type and size check, no request (step 1)
the scale under the bag shows millilitres collected; a sealed bag on a plain hook shows nothing upload progress from xhr.upload (step 3)
a scale switched on after the needle went in shows nothing for that donation listeners before send() (step 4)
a full bag is not yet a unit: the lab tests it, and a failed test discards it done only on a 2xx (steps 5–6)
every donation ends with the donor's card filed, whatever happened loadend (step 7)
the nurse clamps that chair's line, and the clamp is labelled with the chair's number abort() on the row's own request (steps 8–9)
a card stamped "stopped" or "discarded" ignores any later beep from the scale finished states ignore late events (step 11)

In real terms: each file is one request with one row; the row's state changes only while the file is in flight.

Breaks down when: a stopped donation is over for the day; a cancelled upload can start again from the same file, as a new request with a new row state.

Contrasting case: a finger-prick sample: a 40 KB avatar sent with one fetch(), no bar and no Cancel.

Examples, run for real

4.1 ExamHall EXH-3600: a 12.4 MB phone photo

The first ID-photo field sent every picked file (EXH-3600). MR !3900 checks type and size first, against the API's limit of 10 MB (10,000,000 bytes), and reads the status in load.

4.1 EXH-3600 draft, every picked file sent · IMG_2231.jpg, 12,400,000 bytes · requests 1 · body 12,400,000 bytes, all of it before the API can answer
4.1 MR !3900 checks first (10,000,000 bytes, JPEG or PNG) · IMG_2231.jpg · requests 0 · row "IMG_2231.jpg · Rejected: over the size limit"
4.1 MR !3900 · IMG_2232.jpg, 3,100,000 bytes · requests 1 · reply 201 · row "IMG_2232.jpg · Uploaded"

The fixed field makes no request at all (step 1); the draft sent all 12.4 MB before the API could refuse it.

4.2 Media streaming: three clips in one uploader

A creator picks three clips in the reference build, under the app's 50 MB clip limit; she cancels one half-way, and the server accepts another.

4.2 three clips picked, 50,000,000-byte limit · requests 2 (teaser.mp4, interview.mp4) · rows: teaser.mp4 · Uploading 50% | interview.mp4 · Uploading 40% | full-session.mp4 · Rejected: over the size limit
4.2 Cancel on interview.mp4, then 201 for teaser.mp4 · requests aborted: interview.mp4 · rows: teaser.mp4 · Uploaded | interview.mp4 · Cancelled | full-session.mp4 · Rejected: over the size limit
4.2 Chromium 153.0.8010.12 · XMLHttpRequest has an upload object: true · a fetch() Request has one: false · Request members naming progress or upload: 0

Each Cancel aborts its own row's request (steps 8–9); the rejected clip never had one (step 1). In Chromium, a fetch() request has no upload object to listen on (step 3).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 8

A video app's clip uploader:

A creator clicks Upload on intro.mp4, then on outro.mp4. Both bars reach 100% while the server is still checking the clips. She clicks Cancel on intro.mp4. The server then answers 201 to every request still open.

What do the two rows read?

Code for this question
function ClipUploads({ clips }) {  const [status, setStatus] = useState({});  const xhr = useRef(null);   function start(clip) {    const req = new XMLHttpRequest();    xhr.current = req;    req.upload.onprogress = (e) => setStatus((s) => ({ ...s, [clip.name]: `${Math.floor((100 * e.loaded) / e.total)}%` }));    req.onload = () => setStatus((s) => ({ ...s, [clip.name]: req.status === 201 ? 'Done' : 'Failed' }));    req.onabort = () => setStatus((s) => ({ ...s, [clip.name]: 'Cancelled' }));    req.open('POST', '/api/clips');    req.send(clip);  }   function cancel(clip) {    xhr.current.abort();    setStatus((s) => ({ ...s, [clip.name]: 'Cancelled' }));  }   return (    <ul>      {clips.map((clip) => (        <li key={clip.name}>          {clip.name}: {status[clip.name] ?? 'Waiting'}          <button onClick={() => start(clip)}>Upload</button>          <button onClick={() => cancel(clip)}>Cancel</button>        </li>      ))}    </ul>  );}
Pick one

Question 2 of 8

A box office's seat-map uploader logs the abort and loadend events of the request and of xhr.upload, from listeners all added before send(). Its Cancel handler calls xhr.abort() and then logs abort() returned. The venue manager clicks Cancel while the image is half sent.

Put the five log lines in the order they appear, the first one first.

Your order

  1. 1empty
  2. 2empty
  3. 3empty
  4. 4empty
  5. 5empty

Tap the lines in the order you expect.

Question 3 of 8

A payments ledger imports bank statements. Its API docs say: "Statements up to 5 MB (5,000,000 bytes); a larger body gets 413." The page runs check on each picked file, sends one POST for each file that passes, and shows "Upload failed" when the reply is not 2xx:

A clerk picks four files: june.csv (4,900,000 bytes, text/csv), july.pdf (5,100,000 bytes, application/pdf), august.csv (5,300,000 bytes, text/csv) and q3.xlsx (900,000 bytes, a spreadsheet type).

How many requests does the page send, and how many rows end as "Upload failed"?

Code for this question
const ALLOWED = ['text/csv', 'application/pdf'];const MAX_BYTES = 5 * 1024 * 1024; // "5 MB", as the API docs say function check(file) {  if (!ALLOWED.includes(file.type)) return 'Rejected: CSV or PDF only';  if (file.size > MAX_BYTES) return 'Rejected: over 5 MB';  return null;}
Pick one

Question 4 of 8

A chat app's attachment sender:

A member sends a 20 MB video from a train. At 63% the train enters a tunnel and the connection drops; no reply ever comes.

What does the line show after that?

Code for this question
function Attachment({ file }) {  const [status, setStatus] = useState('Sending 0%');   useEffect(() => {    const xhr = new XMLHttpRequest();    xhr.upload.onprogress = (e) => setStatus(`Sending ${Math.floor((100 * e.loaded) / e.total)}%`);    xhr.onerror = () => setStatus('Failed, tap to retry');    xhr.onloadend = () => setStatus('Sent');    xhr.open('POST', '/api/attachments');    xhr.send(file);  }, [file]);   return <p>{file.name}: {status}</p>;}
Pick one

Question 5 of 8

the interviewer says: "Build Uploader({ url, maxBytes, types }): a multi-file uploader. Render a file input (multiple) and a list with one <li> per picked file, showing its name and its state. Reject a file whose type is not in types, or whose size is over maxBytes, without sending it, and say which. Send every other file with its own XMLHttpRequest POST to url; while it goes up, show 'Uploading N%', a <progress> bar and a Cancel button. A 2xx reply makes it 'Uploaded'; any other reply or a dropped connection, 'Upload failed'; Cancel, 'Cancelled'. Once a file is finished, nothing may change its row." You have 35 minutes, and ExamHall's ID-photo step is the use case.

Before you type: say your test list out loud.

Name at least six tests, each with what it does and what it expects.

Question 6 of 8

the interviewer says: "Build Uploader({ url, maxBytes, types }): a multi-file uploader. Render a file input (multiple) and a list with one <li> per picked file, showing its name and its state. Reject a file whose type is not in types, or whose size is over maxBytes, without sending it, and say which. Send every other file with its own XMLHttpRequest POST to url; while it goes up, show 'Uploading N%', a <progress> bar and a Cancel button. A 2xx reply makes it 'Uploaded'; any other reply or a dropped connection, 'Upload failed'; Cancel, 'Cancelled'. Once a file is finished, nothing may change its row." You have 35 minutes, and ExamHall's ID-photo step is the use case.

Now build it, in 35 minutes, from a blank file that exports Uploader, with useState, useRef, useEffect, useReducer and XMLHttpRequest in scope.

Question 7 of 8

A shop admin's product-photo uploader keeps one request per photo in xhrs.current (a Map by id) and moves each row with this reducer:

Each row shows its Cancel button while its status is "Uploading". The 201 for sofa.jpg lands, and the clerk's click on that row's Cancel button is handled right after it, before React has drawn the row again.

What does the row read, and does the shop keep the photo?

Code for this question
function photosReducer(rows, ev) {  return rows.map((row) => {    if (row.id !== ev.id) return row;    if (ev.type === 'progress') return { ...row, pct: ev.pct };    if (ev.type === 'load') return { ...row, status: ev.ok ? 'Uploaded' : 'Upload failed' };    if (ev.type === 'error') return { ...row, status: 'Upload failed' };    if (ev.type === 'cancel') return { ...row, status: 'Cancelled' };    return row;  });} function cancel(id) {  xhrs.current.get(id).abort();  dispatch({ type: 'cancel', id });}
Pick one

Question 8 of 8

A box office's seat-map uploader:

The venue manager uploads an 18 MB seat map; the server reads all of it and answers 201.

What does the line show while the bytes go up, and after the reply?

Code for this question
function SeatMapUpload({ file }) {  const [pct, setPct] = useState(0);  const [done, setDone] = useState(false);   useEffect(() => {    const xhr = new XMLHttpRequest();    xhr.open('POST', '/api/seat-maps');    xhr.send(file);    xhr.upload.onprogress = (e) => setPct(Math.floor((100 * e.loaded) / e.total));    xhr.onload = () => setDone(xhr.status === 201);  }, [file]);   return <p>{done ? 'Uploaded' : `Uploading ${pct}%`}</p>;}
Pick one

Go deeper

Pro tips
  1. Draw the state machine before the JSX. Five states, the events between them, and a ring of "ignored" around each finished state. A cancelled file turning "done" is a missing "finished states ignore events" rule. When: the first two minutes of the build, before any handler (steps 10–11). Verify (RAN, tips.mjs):
    tip 1 UP_IMPL=./drafts/m1-no-terminal-guard.jsx (the reference without the finished-state check): exit 1 · tests 10 · pass 9 · fail 1 (T10)
    (The curriculum row's tip, [RAN], re-executed here with this lesson's test numbers.)
  2. Run your build against the list before you call it done: UP_IMPL=./my-uploader.jsx node --test upload.test.mjs. When: practising the build at home, against the tests the reference passes (step 12). Verify (RAN, tips.mjs):
    tip 2 UP_IMPL=./drafts/m0-empty.jsx (renders nothing): exit 1 · tests 10 · pass 0 · fail 10
Source

Docs change, so their claims are labelled as of 2026-10-12; every behaviour this lesson grades is executed.

Checked against the tools and docs as of 2026-10-12.