React Hook Form 19 ๐ File Uploads โ Handling FileList and validation
File uploads are the one field type where React Hook Form’s uncontrolled model meets a browser API that refuses to be controlled. The <input type="file"> element does not accept a value prop that can be set programmaticallyโthe browser throws a DOMException if you tryโand its value is a FileList object, not a string . The register function still works for the file input, but the value it tracks is the FileList, and the validation rules run against that object rather than a string. The Controller path is the trap: spreading {...field} onto a file input passes the FileList into the value prop, and the browser rejects it . The file input is the case where the uncontrolled register is not just the default, it is the only path that works without the manual synchronization.
The validation for the file input is the same validate rule, but the function receives the FileList and must check the files inside it. The required rule works as usualโthe field is invalid when the FileList is emptyโand the validate function is the mechanism for the size, the type, and the count checks . The FormData construction on the submit is the separate step: the FileList is appended to the FormData, and the FormData is what the fetch sends . The file input is the case where the form’s value is the browser object, and the submission is the manual serialization.
Key point: The <input type="file"> value is a FileList, and the browser refuses the programmatic value assignment. The register works, and the validate rule receives the FileList for the size, the type, and the count checks. The Controller with {...field} throws the DOMException, and the setValue on the file input is the same trap. The submit builds the FormData from the FileList and sends it with the fetch.
Why the file input is the special case
The browser restriction problem. The HTML specification states that the value property of the file input can only be set to the empty string. The browser enforces the rule, and the setValue("avatar", file) or the {...field} spread throws the DOMException: Failed to set the 'value' property on 'HTMLInputElement' . The restriction is the security mechanismโthe script cannot pre-select a file from the user’s diskโand the restriction is the reason the controlled model does not work for the file input.
The FileList problem. The file input’s value is a FileList object, not a string. The FileList is the array-like collection of the File objects, and the collection is the live view of the input’s selection. The register stores the FileList in the form state, and the validate rule receives the FileList . The FileList is the browser object, and the form’s value is the object rather than the primitive.
The Controller trap. The Controller is the recommended wrapper for the controlled components, and the file input is the component that is not controlled. The render={({ field }) => <input {...field} type="file" />} passes the value and the onChange from the field, and the value is the FileList, and the browser throws . The Controller is the trap, and the register is the escape.
The validation problem. The required rule checks the FileList‘s length, and the validate rule receives the FileList for the custom checks. The file.size, the file.type, and the files.length are the properties that the validation reads . The validation is the mechanism that enforces the size limit, the accepted types, and the file count.
The submission problem. The fetch with the JSON.stringify does not work for the files. The FormData is the mechanism that carries the binary, and the formData.append("avatar", fileList[0]) is the mechanism that adds the file . The FormData is the browser object, and the submission is the manual serialization.
a. The register with the FileList and the validation
The register connects the file input, and the validate rule receives the FileList. The required rule is the presence check, and the validate function is the custom check.
// The file input with the register and the validation
import { useForm } from "react-hook-form";
type UploadFormValues = {
avatar: FileList;
};
function UploadForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UploadFormValues>();
const onSubmit = async ({ avatar }: UploadFormValues) => {
// The FileList is the form value.
// The FormData is the mechanism that carries the binary.
const body = new FormData();
body.append("avatar", avatar[0]);
await fetch("/api/avatar", { method: "POST", body });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
type="file"
accept="image/*"
{...register("avatar", {
// The required rule checks the FileList's presence.
required: "Choose a file",
// The validate function receives the FileList.
validate: (files) => {
// The files is the FileList.
if (!files?.[0]) return "Choose a file";
if (files[0].size > 2_000_000) return "Max size is 2 MB";
if (!files[0].type.startsWith("image/")) return "Only images";
return true;
},
})}
/>
{errors.avatar && <p role="alert">{errors.avatar.message}</p>}
<button type="submit">Upload</button>
</form>
);
}
The register("avatar", { required, validate }) is the mechanism that connects the file input. The validate: (files) => { ... } receives the FileList, and the files[0].size and the files[0].type are the checks . The required rule is the presence check, and the custom validate is the size and the type check.
The body.append("avatar", avatar[0]) is the mechanism that adds the file to the FormData. The avatar is the FileList, and the avatar[0] is the first File. The FormData is what the fetch sends, and the fetch is the submission .
The multi-file input is the same pattern with the multiple attribute. The FileList contains the multiple File objects, and the FormData appends each one.
// The multi-file input
<input
type="file"
multiple
{...register("documents", {
validate: (files) => {
if (!files || files.length === 0) return "Choose at least one file";
if (files.length > 5) return "At most 5 files";
for (const file of Array.from(files)) {
if (file.size > 5_000_000) return "Each file must be under 5 MB";
}
return true;
},
})}
/>
// The submission
const body = new FormData();
for (const file of Array.from(avatar)) {
body.append("documents", file);
}
The for (const file of Array.from(files)) is the mechanism that iterates the FileList. The Array.from is the mechanism that converts the FileList to the array, and the conversion is the reason the for...of worksโthe FileList is the array-like but not the iterable in the older browsers.
b. The Controller trap and the manual synchronization
The Controller with the file input is the trap. The {...field} spread passes the value to the input, and the browser throws. The onChange with the field.onChange is the partial fix, but the value is still the problem.
// The Controller trap
<Controller
name="avatar"
control={control}
render={({ field }) => (
// The {...field} spread passes the value to the file input.
// The browser throws the DOMException.
<input {...field} type="file" />
)}
/>
The fix is the onChange only, and the value is the omitted. The field.onChange(event.target.files) is the mechanism that updates the form state, and the value is the omitted because the file input cannot be controlled .
// The Controller without the value
<Controller
name="avatar"
control={control}
render={({ field }) => (
<input
// The value is omitted because the file input cannot be controlled.
type="file"
onChange={(event) => field.onChange(event.target.files)}
ref={field.ref}
/>
)}
/>
The onChange={(event) => field.onChange(event.target.files)} is the mechanism that passes the FileList to the form. The ref={field.ref} is the mechanism that connects the input to the form for the focus and the validation . The value is the omitted, and the omission is the reason the browser does not throw.
The setValue on the file input is the same trap. The setValue("avatar", file) throws because the file input cannot be set programmatically . The setValue is the mechanism that works for the other fields, and the file input is the exception.
Complete Example Session
// ============================================
// PART 1: THE BASIC FILE INPUT
// ============================================
import { useForm } from "react-hook-form";
type UploadFormValues = {
avatar: FileList;
};
function UploadForm() {
const { register, handleSubmit, formState: { errors } } = useForm<UploadFormValues>();
const onSubmit = async ({ avatar }: UploadFormValues) => {
const body = new FormData();
body.append("avatar", avatar[0]);
await fetch("/api/avatar", { method: "POST", body });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
type="file"
accept="image/*"
{...register("avatar", {
required: "Choose a file",
})}
/>
{errors.avatar && <p role="alert">{errors.avatar.message}</p>}
<button type="submit">Upload</button>
</form>
);
}
// ============================================
// PART 2: THE SIZE VALIDATION
// ============================================
<input
type="file"
{...register("avatar", {
validate: (files) =>
!files?.[0] || files[0].size < 2_000_000 || "Max size is 2 MB",
})}
/>
// ============================================
// PART 3: THE TYPE VALIDATION
// ============================================
<input
type="file"
accept="image/*"
{...register("avatar", {
validate: (files) =>
!files?.[0] || files[0].type.startsWith("image/") || "Only images",
})}
/>
// ============================================
// PART 4: THE MULTI-FILE
// ============================================
<input
type="file"
multiple
{...register("documents", {
validate: (files) =>
!files || files.length <= 5 || "At most 5 files",
})}
/>
// ============================================
// PART 5: THE CONTROLLER TRAP
// ============================================
// โ The {...field} spread throws the DOMException.
<Controller name="avatar" render={({ field }) => <input {...field} type="file" />} />
// โ
The onChange only.
<Controller
name="avatar"
render={({ field }) => (
<input
type="file"
onChange={(e) => field.onChange(e.target.files)}
ref={field.ref}
/>
)}
/>
// ============================================
// PART 6: THE SETVALUE TRAP
// ============================================
// โ The setValue throws on the file input.
setValue("avatar", file);
// โ
The register or the Controller onChange.
// The file input cannot be set programmatically.
// ============================================
// PART 7: THE FORMDATA
// ============================================
const body = new FormData();
body.append("avatar", avatar[0]);
await fetch("/api/avatar", { method: "POST", body });
// The FormData is the mechanism for the binary.
// ============================================
// PART 8: THE PREVIEW
// ============================================
import { useState } from "react";
const [preview, setPreview] = useState<string | null>(null);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setPreview(URL.createObjectURL(file));
}
};
// The URL.createObjectURL is the mechanism for the preview.
// ============================================
// PART 9: THE ZOD SCHEMA
// ============================================
import { z } from "zod";
const schema = z.object({
avatar: z.instanceof(File).optional(),
});
// The z.instanceof(File) is the Zod check.
// The ref={field.ref} is required for the Zod validation.
// ============================================
// PART 10: THE CHECKLIST
// ============================================
// [ ] The register is used for the file input.
// [ ] The validate rule receives the FileList.
// [ ] The FormData carries the file on the submit.
// [ ] The Controller uses the onChange only, not the value.
// [ ] The setValue is not used on the file input.
// [ ] The ref={field.ref} is passed for the Zod validation.
The ten parts covered the basic file input, the size validation, the type validation, the multi-file, the Controller trap, the setValue trap, the FormData, the preview, the Zod schema, and the checklist. The through-line is that the file input is the browser object, and the register is the path that respects the browser.
Quick Reference
The file input constraints
| Constraint | Detail |
|---|---|
| The value type | The FileList |
| The programmatic value | Only the empty string |
The register | Works |
The Controller {...field} | Throws the DOMException |
The setValue | Throws the DOMException |
The validation rules
| Rule | Purpose |
|---|---|
required | The FileList is not empty |
validate | The custom check receives the FileList |
| The size check | files[0].size |
| The type check | files[0].type |
| The count check | files.length |
The submission
| Mechanism | Purpose |
|---|---|
The FormData | Carries the binary |
The body.append | Adds the file |
The fetch with the body | Sends the file |
The preview
| Mechanism | Purpose |
|---|---|
The URL.createObjectURL | The blob URL |
The useState | The preview state |
Best Practices
โ Do This:
// Use the register for the file input
<input type="file" {...register("avatar", { validate: fn })} /> // โ
// Use the FormData for the submission
const body = new FormData(); body.append("avatar", file); // โ
// Use the onChange only in the Controller
onChange={(e) => field.onChange(e.target.files)} // โ
// Pass the ref for the Zod validation
ref={field.ref} // โ
โ Don’t Do This:
// Spread the field onto the file input
<input {...field} type="file" /> // the DOMException // โ
// Use the setValue on the file input
setValue("avatar", file); // the DOMException // โ
// Send the FileList as the JSON
JSON.stringify({ avatar }); // the empty object // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
The DOMException on the value | The {...field} spread | Use the onChange only |
| The file is not sent | The JSON.stringify | Use the FormData |
| The Zod validation fails | The ref is not passed | Add the ref={field.ref} |
| The file is missing on the submit | The setValue is used | Use the register or the onChange |
Real-World Examples
1. The basic register
<input type="file" {...register("avatar")} />
2. The required rule
register("avatar", { required: "Choose a file" })
3. The size validation
register("avatar", { validate: (f) => !f?.[0] || f[0].size < 2_000_000 || "Too large" })
4. The type validation
register("avatar", { validate: (f) => !f?.[0] || f[0].type.startsWith("image/") || "Only images" })
5. The multi-file
<input type="file" multiple {...register("documents")} />
6. The Controller onChange
onChange={(e) => field.onChange(e.target.files)}
7. The FormData
const body = new FormData(); body.append("avatar", avatar[0]);
8. The preview
setPreview(URL.createObjectURL(file))
9. The Zod schema
z.instanceof(File)
10. The ref
ref={field.ref}
Visual
The FileList Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE FILELIST FLOW โ
โ โ
โ The user selects the file โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The FileList โ The browser object โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The validate rule โ The size, the type, the count โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The FormData โ The submission โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Controller Trap
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE CONTROLLER TRAP โ
โ โ
โ โ The {...field} spread: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The value is the โ The browser throws the โ
โ โ FileList โ DOMException โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โ
The onChange only: โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The value is โ The browser accepts โ
โ โ omitted โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Validation
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE VALIDATION โ
โ โ
โ The FileList โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ files[0].size โ The size check โ
โ โ files[0].type โ The type check โ
โ โ files.length โ The count check โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| The file input value | The FileList |
| The programmatic value | Only the empty string |
The register | Works, the FileList is the value |
The validate | Receives the FileList |
The Controller | The onChange only, no value |
| The submission | The FormData |
| The Zod validation | The ref={field.ref} is required |
Key takeaways:
- The file input value is a
FileList, and the browser refuses the programmatic assignment. Theregisterworks, and thevalidaterule receives theFileList. TheControllerwith the{...field}spread and thesetValuethrow theDOMException. - The
validaterule receives theFileList. Thefiles[0].size, thefiles[0].type, and thefiles.lengthare the checks for the size, the type, and the count . - The
FormDatacarries the file on the submit. Thebody.append("avatar", fileList[0])is the mechanism, and thefetchwith thebodyis the submission. TheJSON.stringifydoes not work for the binary . - The
Controlleruses theonChangeonly. Thefield.onChange(event.target.files)passes theFileList, and thevalueis the omitted. Theref={field.ref}is the mechanism that connects the input for the Zod validation . - The preview uses the
URL.createObjectURL. ThesetPreview(URL.createObjectURL(file))is the mechanism that shows the selected image, and the preview is the UX feedback.
Remember: The React Hook Form file upload is the register and the FileList. The file input cannot be controlled, and the Controller with the value throws. The validate rule receives the FileList, and the FormData carries the file. The preview is the URL.createObjectURL, and the Zod validation needs the ref.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!