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?
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> );}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
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?
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> );}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
- 1empty
- 2empty
- 3empty
- 4empty
- 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"?
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;}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?
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>;}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?
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 });}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?
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>;}Go deeper
Pro tips
- 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):
(The curriculum row's tip, [RAN], re-executed here with this lesson's test numbers.)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) - 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.
- MDN, XMLHttpRequestUpload (the curriculum row's first source), https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequestUpload (
mdn-xhr-upload.txt). "The XMLHttpRequestUpload interface represents the upload process for a specific XMLHttpRequest." (step 3). As of 2026-10-12. - MDN, XMLHttpRequest: abort() method (the row's second source), https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/abort (
mdn-xhr-abort.txt). "If the request is still in progress (its readyState is not XMLHttpRequest.DONE or XMLHttpRequest.UNSENT), a readystatechange event, abort, and a loadend event are dispatched, in that order." (step 8). As of 2026-10-12. - Front End Interview Handbook, Front end user interface coding interviews (the row's third source), https://www.frontendinterviewhandbook.com/coding/build-front-end-user-interfaces (
feih-ui.txt). Among its practice questions: "Progress Bars (Free)". It keys nothing here. As of 2026-10-12. - WHATWG, XMLHttpRequest Living Standard, https://xhr.spec.whatwg.org/ (
whatwg-xhr.txt). Insend(): "If one or more event listeners are registered on this’s upload object, then set this’s upload listener to true." (step 4); the abort() steps: "If this’s state is done, then set this’s state to unsent and this’s response to a network error." (step 8); the request error steps, whichabort()runs on a request in flight: "Fire a progress event named event at xhr’s upload object with 0 and 0." comes before "Fire a progress event named event at xhr with 0 and 0.". As of 2026-10-12. - MDN, XMLHttpRequest: load event, https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/load_event (
mdn-xhr-load.txt). "The load event is fired when an XMLHttpRequest transaction completes successfully." "Successfully" here means a reply arrived: Chromium firesloadfor 413 and 500 (xhr-events.mjs; step 6). As of 2026-10-12. - MDN, XMLHttpRequest: loadend event, https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/loadend_event (
mdn-loadend.txt). "The loadend event is fired when a request has completed, whether successfully (after load) or unsuccessfully (after abort or error)." (step 7). As of 2026-10-12. - MDN,
<input type="file">, https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file (mdn-input-file.txt). "The accept attribute doesn't validate the types of the selected files; it provides hints for browsers to guide users towards selecting the correct file types." (step 1). As of 2026-10-12. - MDN, Blob: size property, https://developer.mozilla.org/en-US/docs/Web/API/Blob/size (
mdn-blob-size.txt). "the size of the Blob or File in bytes." (step 2). As of 2026-10-12.
Related lessons
Checked against the tools and docs as of 2026-10-12.