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:

  1. The value is a handle to binary data. A File object 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 a FileList obtained from another input or a DataTransfer), which rules out the “controlled input” model.
  2. 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.
  3. Upload is asynchronous and long. It needs progress, cancellation, retry and — for large files — resumption.
  4. 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.

Two ways to submit a form with files Sending files inside the submit as multipart form data is simple and atomic but gives one progress bar for everything, fails the whole submit if any file fails, retries by resending every file, and cannot support drafts that include files without re-uploading. Uploading files first to a separate endpoint and submitting only their ids gives per-file progress and errors, retries only the failed file, lets drafts store the ids, and requires the server to clean up orphaned uploads. Aspect Files inside submit Upload first, submit ids Progress one bar for everything per file A file fails whole submit fails only that file Retry resend every file resend one file Drafts files lost or re-uploaded store ids Server work one endpoint upload endpoint + orphan cleanup For anything beyond a single small attachment, upload-first is the architecture that keeps errors and retries per file.

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.

One file's lifecycle A file enters as selected when the user chooses or drops it. Local validation checks type, size and content; failures go to rejected with a specific message and never upload. Passing files start uploading with progress and a cancel control; network failures go to failed with a retry option. On success the server returns an id and the file is uploaded. If the server scans or transcodes, the item is processing until it is ready or rejected by the server. Only ready items count toward a valid submit. selected File object held in state. Local checks run immediately: type, size, content. checks pass uploading Progress and a Cancel button. Network error → failed (Retry). Cancel → removed. server returns id uploaded → processing Server scans or transcodes. Server may still reject: malware, unreadable, wrong content. server confirms ready The id is safe to submit. Only ready items make the field valid.

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.

The modules around a file field Local validation runs on selection and rejects files by size, declared type, sniffed type and image dimensions with messages naming the file. The uploader moves items through uploading, failed, processing and ready, with progress, cancel and retry. The preview module creates object URLs and revokes them when items are removed or the field unmounts. The submit gate refuses to submit while any item is busy, lists items needing attention, and sends only ready server ids. Local validation Size, type, sniffed bytes, dimensions. Messages name the file. Uploader Progress, cancel, retry. Resumable for large files. Previews Object URLs. Revoked on remove and unmount. Submit gate Busy? explain and wait. Send ready ids only.

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.

What to test for every file field A file over the size limit is rejected locally with a message naming the file and no upload request is made. A file whose bytes do not match its extension is rejected with a type message. Pressing submit while an upload is in progress does not submit and announces that files are still uploading. Removing a file mid-upload aborts its request and revokes its preview. A server-side rejection after upload shows the same error treatment as a local rejection. Case Setup Expect too large buffer of limit + 1 byte rejected locally; no request sent wrong content PNG bytes named .pdf type message naming the file submit while busy slow upload, press Submit no submit; "still uploading" announced remove mid-upload remove during progress request aborted; URL revoked server rejects upload ok, scan fails item rejected with server reason

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 createObjectURL needs a matching revokeObjectURL.
  • 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.


Related

← Form State Fundamentals & Architecture