| | |

React Hook Form 19 ๐Ÿ“‹ File Uploads โ€” Handling FileList and validation

โฑ๏ธ Estimated read: 13 min | |

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

ConstraintDetail
The value typeThe FileList
The programmatic valueOnly the empty string
The registerWorks
The Controller {...field}Throws the DOMException
The setValueThrows the DOMException

The validation rules

RulePurpose
requiredThe FileList is not empty
validateThe custom check receives the FileList
The size checkfiles[0].size
The type checkfiles[0].type
The count checkfiles.length

The submission

MechanismPurpose
The FormDataCarries the binary
The body.appendAdds the file
The fetch with the bodySends the file

The preview

MechanismPurpose
The URL.createObjectURLThe blob URL
The useStateThe 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

PitfallWhy It HappensFix
The DOMException on the valueThe {...field} spreadUse the onChange only
The file is not sentThe JSON.stringifyUse the FormData
The Zod validation failsThe ref is not passedAdd the ref={field.ref}
The file is missing on the submitThe setValue is usedUse 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

ItemValue
The file input valueThe FileList
The programmatic valueOnly the empty string
The registerWorks, the FileList is the value
The validateReceives the FileList
The ControllerThe onChange only, no value
The submissionThe FormData
The Zod validationThe ref={field.ref} is required

Key takeaways:

  • The file input value is a FileList, and the browser refuses the programmatic assignment. The register works, and the validate rule receives the FileList. The Controller with the {...field} spread and the setValue throw the DOMException .
  • The validate rule receives the FileList. The files[0].size, the files[0].type, and the files.length are the checks for the size, the type, and the count .
  • The FormData carries the file on the submit. The body.append("avatar", fileList[0]) is the mechanism, and the fetch with the body is the submission. The JSON.stringify does not work for the binary .
  • The Controller uses the onChange only. The field.onChange(event.target.files) passes the FileList, and the value is the omitted. The ref={field.ref} is the mechanism that connects the input for the Zod validation .
  • The preview uses the URL.createObjectURL. The setPreview(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.

Was this helpful?


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!