React
React File Uploads: Honest Progress and Reliable Cancellation
By Vivek Jalondhara · 5 min read
Published
Separate transmitted bytes from processing completion, cancel the current upload, and keep late events from replacing a newer attempt.
An upload reaches 100 percent, yet the price sheet is not ready. The browser has sent the bytes, but the server may still be storing the file or starting extraction. If the interface calls that moment complete, users can retry unnecessarily or leave before receiving a usable result.
This illustrative Sheet Parser workflow separates file transport from parsing and review. The example uses XMLHttpRequest for its upload progress events and cancellation API; it does not describe the project’s production storage service or upload protocol.
Name each stage honestly
Use distinct states for selected, uploading, waiting for acknowledgement, processing, ready for review, failed, and cancelled when those stages exist in the API. Each label should answer what the user can do next. Processing starts only when the server confirms that a processing job exists.
Transferred bytes are evidence about transport, not accepted business data. A full progress bar can accompany “Upload sent; waiting for server” without pretending the import has finished. A successful upload response should identify the server resource or job that the next screen will monitor.
Represent unknown progress explicitly
Listen to progress events on xhr.upload. Calculate a percentage only when lengthComputable is true and total is positive; otherwise use an indeterminate indicator. Do not manufacture a percentage from elapsed time, because a slow connection and a large file can make that estimate actively misleading.
The example reports null for unknown progress and resolves only after a successful HTTP response. Its Promise resolves without parsing a body so the transport behavior stays visible. A real API adapter should validate the response shape before announcing that a stored upload or job can be opened.
Cancel the transport and identify the attempt
Keep the active upload handle in a ref owned by the upload controller. A Cancel action calls abort, while the rejected Promise is classified as a cancellation rather than a generic network error. The UI should leave the selected file available when retrying it remains a useful action.
Give every attempt an identifier. Before an event updates React state, check that it still belongs to the active attempt. Increment or replace that identifier before aborting an old request and starting another, because abort and completion events can otherwise overwrite the new attempt’s status.
Explain what cancellation can guarantee
Aborting the browser request stops that client operation. It cannot prove that the server did not already receive the file or create a processing job. If the product supports cancelling server work, expose a separate job cancellation operation and show its confirmed outcome.
This matters after a network timeout: the browser may not know whether the server accepted the upload. Reconcile using an upload identifier or an idempotency policy before retrying an operation that creates a new import. A changed button label cannot resolve that ambiguity.
Put cleanup at the owner boundary
React Effect cleanup is appropriate for disposing a controller when its owner unmounts or changes identity. Whether navigation should cancel an upload is a product decision: a page-owned controller can stop on exit, while a persistent upload queue needs an owner above the page.
Use an explicit transport timeout and distinguish timeout, network failure, HTTP rejection, and user cancellation. The sample timeout is illustrative, not a universal limit. For cross-origin uploads, verify the server’s CORS policy; attaching upload listeners can cause a preflight request that the endpoint must support.
Make retries understandable
Keep the filename, selected vendor, and project context visible after failure, but recheck that they still belong to the active scope. Disable duplicate submission while one attempt is active. If a user selects a different file, explain which attempt is being replaced instead of leaving two competing progress indicators.
Exercise unknown totals, a rejected HTTP response after all bytes were sent, cancellation just before completion, and replacing a file during upload. Browser event behavior still needs integration testing; a transport mock can verify state transitions without proving every supported browser behaves identically.
Expose progress and a cancellable result
This browser transport posts multipart data to an illustrative same-origin endpoint. The React owner must catch the result, guard events by attempt identity, and validate any response contract it needs.
Illustrative example · TypeScript · Simplified for explanation
function uploadSheet(file: File, progress: (percent: number | null) => void) {
const xhr = new XMLHttpRequest();
const result = new Promise<void>((resolve, reject) => {
xhr.upload.onprogress = event => progress(
event.lengthComputable && event.total > 0
? Math.round(event.loaded / event.total * 100) : null
);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload rejected: HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Upload connection failed"));
xhr.ontimeout = () => reject(new Error("Upload timed out"));
xhr.onabort = () => reject(new DOMException("Upload cancelled", "AbortError"));
xhr.open("POST", "/api/uploads");
xhr.timeout = 60_000;
const body = new FormData();
body.append("file", file);
xhr.send(body);
});
return { result, cancel: () => xhr.abort() };
}- Create a uniquely identified attempt
- Report transmitted bytes
- Await server acknowledgement
- Monitor processing or show a recovery action
Conceptual flow for the example above.
Tradeoff to keep in mind
XHR provides direct upload progress events, but transport cancellation and server job cancellation are separate operations. Large files may justify a resumable protocol beyond this small example.