A file field is the one input whose value is not a string, cannot be set by script, may take minutes to “finish”, and can fail halfway — so treating it like a text input produces forms that submit before uploads complete, lose attachments on reload, leak memory on every preview and report a 413 from the server as “something went wrong”.
Most of form state fundamentals and architecture assumes that a field’s value changes instantly when the user acts and that validation is cheap. Files break both assumptions. Choosing a file is instant; making it usable by the server — uploading it, having it scanned, receiving an id back — is a long-running process with its own states, its own failures and its own progress. This topic models that process explicitly and shows how it plugs into validation, submission, drafts and accessibility, with guides for each part.
Problem statement
Four properties set file fields apart:
- The value is a handle to binary data. A
Fileobject references bytes on disk. You can read, slice and upload it, but you cannot put a file into an<input type="file">from script (only by assigning aFileListobtained from another input or aDataTransfer), which rules out the “controlled input” model. - Most validation can and should happen before upload. Size, type, image dimensions and page count are all knowable locally. Uploading a 200 MB video to be told the limit is 20 MB wastes minutes and data.
- Upload is asynchronous and long. It needs progress, cancellation, retry and — for large files — resumption.
- The form’s submit depends on it. Either the files travel with the submit (one big multipart request), or they are uploaded first and the submit carries references. Each has consequences for errors and for what happens when the user presses Submit mid-upload.
The pattern applies to any form with attachments: document uploads in applications, avatars in profiles, receipts in expense forms, media in content editors.
State machine specification
Each file in a file field is its own item with its own lifecycle. The field’s validity is derived from its items.
| State | Entered by | Leaves on |
|---|---|---|
selected |
User chooses or drops a file | Local validation passes → uploading; fails → rejected |
rejected |
Type, size or content check failed locally | User removes it or replaces it |
uploading |
Upload request started | Progress events; success → uploaded; network error → failed; cancel → removed |
failed |
Network or 5xx error | Retry → uploading; remove |
uploaded |
Server returned an id | Server-side scan pending → processing, or ready |
processing |
Server is scanning or transcoding | Poll/push → ready or rejected (e.g. malware, unreadable) |
ready |
Server confirmed the file is usable | Remove |
The field is valid for submit only when every item is ready (or uploaded if your server does not process files) and count limits are met. It is busy while any item is uploading or processing — which determines what pressing Submit does.
Core implementation
export type FileItemState =
| { kind: "selected" }
| { kind: "rejected"; reason: string }
| { kind: "uploading"; loaded: number; total: number; abort: () => void }
| { kind: "failed"; reason: string }
| { kind: "processing"; serverId: string }
| { kind: "ready"; serverId: string };
export interface FileItem {
id: string; // client id, stable across state changes
file: File;
previewUrl?: string; // object URL, revoked when the item is removed
state: FileItemState;
}
export interface FileFieldRules {
accept: string[]; // MIME types or extensions: ["image/png", ".pdf"]
maxBytes: number;
maxFiles: number;
}
export function fieldStatus(items: FileItem[], rules: FileFieldRules) {
const busy = items.some((i) => i.state.kind === "uploading" || i.state.kind === "processing");
const problems = items.filter((i) => i.state.kind === "rejected" || i.state.kind === "failed");
const ready = items.filter((i) => i.state.kind === "ready");
return {
busy,
// Valid for submit: nothing pending, nothing broken, count within limits.
valid: !busy && problems.length === 0 && ready.length <= rules.maxFiles,
serverIds: ready.map((i) => (i.state as { serverId: string }).serverId),
problems,
};
}
// Submit handler: never submit while busy; say what is happening instead.
export async function onSubmit(items: FileItem[], rules: FileFieldRules,
announce: (msg: string) => void, send: (ids: string[]) => Promise<void>) {
const s = fieldStatus(items, rules);
if (s.busy) {
announce("Your files are still uploading. The form will be ready to send when they finish.");
return;
}
if (!s.valid) {
announce(`${s.problems.length} file(s) need attention before you can send the form.`);
return;
}
await send(s.serverIds);
}
The field component owns a list of FileItems keyed by client id — the same identity-over-position rule as dynamic field arrays and repeatable groups, because users add and remove files in any order. The upload itself is a separate module that moves items between states; the guides below implement each transition.
Integration guidance
Local validation. Check size and declared type immediately on selection, then sniff the real type from the file’s first bytes, and for images check dimensions — all before any byte is uploaded. Validating file type and size before upload implements the checks and their messages.
Upload and progress. fetch does not report upload progress in current browsers; XMLHttpRequest does via xhr.upload.onprogress. Use XHR (or a library over it) for any file large enough to need a progress bar, with AbortController-style cancellation wired to xhr.abort(). See upload progress and cancellation with XHR and fetch.
Previews. Image previews via URL.createObjectURL(file) are instant and cheap — provided every URL is revoked when its item is removed or replaced. Otherwise each preview pins the file’s memory until the page unloads; image previews with object URLs without leaks shows the ownership model.
Large files. Anything that takes more than a minute to upload on a typical connection needs resumption: chunking, server-side offsets and retry of individual chunks — covered in resumable chunked uploads for large files.
Drafts. With upload-first, a draft stores server ids plus file names and sizes, and restoring a draft shows those items as ready without re-uploading. Files still uploading when the draft is saved can be stored as File objects in IndexedDB, per storing large drafts in IndexedDB, and resumed on restore.
Errors and summaries. Rejected and failed items are field errors on the file field; name the file in the message (“report.docx is 31 MB; the limit is 20 MB”) and list each in the error summary with a link to the item’s Remove or Retry button.
Offline. Queued submissions that include file references depend on the uploads having completed; if files are still local, queue the uploads themselves, as in queueing form submissions while offline.
Security boundaries: what the client can and cannot promise
Client-side checks on files are a usability feature, not a security control. Everything the browser tells you about a file — its name, its type, even its bytes — is under the control of whoever supplies it, and a determined user can bypass your JavaScript entirely and post directly to the upload endpoint. That does not make client checks pointless; it defines their job. They exist so that honest users learn about problems in milliseconds instead of minutes, and so that the upload endpoint receives far fewer obviously wrong files.
The server must therefore repeat every check: size limits enforced while streaming the body (not after buffering it), content-type determined from the bytes, image decoding in a sandbox, archive contents inspected before extraction, and malware scanning where the files will be opened by other people. The processing state in the lifecycle above is where those server checks surface in the UI, and the client should treat a server rejection exactly like a local one: the item shows the reason, the field is invalid, and the user can remove or replace it.
Two further boundaries matter for forms. First, file names are untrusted display text — render them as text, never as HTML, and truncate very long names in the middle so the extension stays visible. Second, uploaded files are not yet part of the record. Until the form is submitted and the server links the file ids to a record, uploaded files are provisional; the server should refuse to link ids that belong to another user’s session, which prevents one user from attaching someone else’s upload by guessing ids.
Keeping these boundaries explicit also simplifies the client. It does not need to be perfect at type detection or exhaustive about formats; it needs to catch the common mistakes quickly and present the server’s verdict clearly when the server knows better.
Edge cases and failure modes
Selecting the same file twice. An <input type="file"> does not fire change if the user picks the same file again, because the value did not change. Reset input.value = "" after reading the selection so re-picking a file (for example after removing it) works.
Drag and drop. Dropped items can include directories and non-file entries. Filter dataTransfer.files, and treat a drop of ten files onto a single-file field as a validation error rather than silently keeping the first.
Mobile camera capture. accept="image/*" with capture opens the camera. Photos from phone cameras are large (several MB) and may be HEIC on iOS, which many servers do not accept; decide whether to convert on the client or accept HEIC.
Replacing a file. In a single-file field, choosing a new file while the old one is uploading must abort the old upload and revoke its preview, not leave an orphaned request running.
Server rejects after upload. Malware scanning or content checks can reject a file that uploaded successfully. The item moves to rejected with the server’s reason, the same as a local rejection, so the UI has one error path.
Hidden input styling. Custom-styled upload buttons that hide the native input with display: none make it unreachable by keyboard. Visually hide it instead and style its label, or trigger it from a real <button> with input.click().
Troubleshooting reference
| Symptom | Diagnostic step | Recovery |
|---|---|---|
| Form submits without the attachment | Check whether submit waited for uploads; log the field status at submit | Gate submit on busy and send only ready ids |
| Memory grows with every image picked | Heap snapshot: look for detached Blobs held by object URLs |
Revoke each URL on remove, replace and unmount |
| Progress bar jumps from 0 to 100% | Check whether the upload uses fetch |
Use XHR’s upload.onprogress for progress |
| Re-picking the same file does nothing | Check whether input.value is still set |
Clear input.value after handling change |
| 413 shown as a generic error | Inspect the response status of the upload | Validate size locally; map 413 to the size message |
Testing and QA hooks
Give each file item a data-file-id with its client id and a data-file-state mirroring the state machine, so end-to-end tests can wait for [data-file-state="ready"] rather than sleeping. Playwright’s setInputFiles accepts in-memory buffers, which makes size-limit and type-sniffing tests fast: construct a buffer with a PNG signature but a .pdf name, and assert the type-mismatch message.
For accessibility, assert that progress is exposed through a progress element or role="progressbar" with an accessible name that includes the file name, that completion and failure are announced through a polite live region, and that Remove and Retry buttons have names such as “Remove report.pdf”. End-to-end form error tests with Playwright covers the live-region assertions.
Common pitfalls
- Submitting while uploads are still running. The form sends before the file ids exist; gate submit on the field’s busy state.
- Validating only on the server. A 20 MB limit discovered after a 3-minute upload is a failure of the client.
- Trusting the extension or the browser’s
file.type. Both come from the file name; sniff the bytes for anything security- or processing-sensitive (and validate on the server regardless). - Leaking object URLs. Every
createObjectURLneeds a matchingrevokeObjectURL. - Hiding the native input with
display: none. It removes the control from the keyboard and accessibility tree.
Frequently Asked Questions
Can I make a file input controlled like a text input?
Not in the usual sense: script cannot set an arbitrary file into an input for security reasons. Keep the file input uncontrolled as a picker, read the File objects on change, store them in your own state as items, and render the list from state. The input’s own value can be cleared after each pick.
Should uploads start immediately on selection?
Usually yes, for upload-first designs: by the time the user finishes the rest of the form, the files are ready, and failures surface while they can still act on them. Start only after local validation passes, and make it cancellable. If files are sensitive and the user may abandon the form, consider deferring until submit and accept the slower final step.
How should upload progress be announced to screen readers?
Do not announce every percentage. Expose a progress element with an accessible name that screen-reader users can query, and announce only start, completion and failure through a polite live region (“report.pdf uploaded”, “photo.jpg failed to upload, retry available”).
Who deletes files that were uploaded but never submitted?
The server. Uploaded-but-unreferenced files should expire after a period (a day is common) unless a submitted record references them. The client can also send a delete request when the user removes an uploaded file, but it cannot be relied on — tabs close.