React Hook Form: Working with Multipart Form Data and File Uploads

Updated on · 7 min read
React Hook Form: Working with Multipart Form Data and File Uploads

React Hook Form is a library designed for building efficient and reusable forms in React. It enables the creation of user-friendly and customizable forms. A common feature in forms is the ability to upload files, also known as multipart form data. In this post, we will explore how to handle multipart form data using React Hook Form and test the file upload functionality using React Testing Library. This approach is versatile and fits various use-cases, whether you are building a simple contact form, multistep form or even a complex dynamic form.

To demonstrate working with multipart form data using React Hook Form, we will add picture upload functionality into a recipe form from an earlier post. The final code can also be found on GitHub.

What is Multipart Form Data?

Multipart form data is a data type used for uploading files or other binary data through a web form. In HTTP, multipart/form-data encoding is used when submitting forms containing files, non-ASCII data, or binary data. It enables sending multiple pieces of data as a single entity.

When a form is submitted using multipart form data encoding, the browser generates a unique boundary string to separate each data part. Each part contains headers that provide information about the content type and field name, followed by the actual field value. This structure allows the receiving server to accurately parse and process the form data, regardless of the mix of text fields and files submitted.

While the multipart/form-data encoding method works directly with HTML forms, the same data format can be constructed in JavaScript using the FormData interface.

Adding a file input

To enhance the functionality of our recipe form from a previous tutorial, we will incorporate a Picture field, allowing users to upload images of dishes created from the recipe. We'll be using the Field component to abstract the repeated rendering logic and improve accessibility. To achieve this, the first step is to add a file input to the form.

jsx
import styled from "@emotion/styled"; import { useForm, Controller } from "react-hook-form"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; import { NumberInput } from "./NumberInput.js"; export const RecipeForm = ({ saveData }) => { const { register, handleSubmit, formState: { errors }, control, } = useForm(); const submitForm = (formData) => { saveData(formData); }; return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name}> <Input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Picture" error={errors.picture}> <Input {...register("picture", { required: "Recipe picture is required", })} type="file" id="picture" /> </Field> <Field label="Description" error={errors.description}> <TextArea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount} htmlFor="amount"> <Controller name="amount" control={control} defaultValue={0} render={({ field: { ref, ...field } }) => ( <NumberInput {...field} type="number" id="amount" /> )} rules={{ max: { value: 10, message: "Maximum number of servings is 10", }, }} /> </Field> </FieldSet> <FieldSet label="Ingredients">// ...</FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); }; const Container = styled.div` display: flex; flex-direction: column; max-width: 700px; `; const Input = styled.input` padding: 10px; width: 100%; border: 1px solid #d9d9d9; border-radius: 6px; `; const TextArea = styled.textarea` padding: 4px 11px; width: 100%; border: 1px solid #d9d9d9; border-radius: 6px; `; const Button = styled.button` font-size: 14px; cursor: pointer; padding: 0.6em 1.2em; border: 1px solid #d9d9d9; border-radius: 6px; margin-right: auto; background-color: ${({ variant }) => variant === "primary" ? "#3b82f6" : "white"}; color: ${({ variant }) => (variant === "primary" ? "white" : "#213547")}; `;
jsx
import styled from "@emotion/styled"; import { useForm, Controller } from "react-hook-form"; import { FieldSet } from "./FieldSet.js"; import { Field } from "./Field.js"; import { NumberInput } from "./NumberInput.js"; export const RecipeForm = ({ saveData }) => { const { register, handleSubmit, formState: { errors }, control, } = useForm(); const submitForm = (formData) => { saveData(formData); }; return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name}> <Input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Picture" error={errors.picture}> <Input {...register("picture", { required: "Recipe picture is required", })} type="file" id="picture" /> </Field> <Field label="Description" error={errors.description}> <TextArea {...register("description", { maxLength: { value: 100, message: "Description cannot be longer than 100 characters", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount} htmlFor="amount"> <Controller name="amount" control={control} defaultValue={0} render={({ field: { ref, ...field } }) => ( <NumberInput {...field} type="number" id="amount" /> )} rules={{ max: { value: 10, message: "Maximum number of servings is 10", }, }} /> </Field> </FieldSet> <FieldSet label="Ingredients">// ...</FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> ); }; const Container = styled.div` display: flex; flex-direction: column; max-width: 700px; `; const Input = styled.input` padding: 10px; width: 100%; border: 1px solid #d9d9d9; border-radius: 6px; `; const TextArea = styled.textarea` padding: 4px 11px; width: 100%; border: 1px solid #d9d9d9; border-radius: 6px; `; const Button = styled.button` font-size: 14px; cursor: pointer; padding: 0.6em 1.2em; border: 1px solid #d9d9d9; border-radius: 6px; margin-right: auto; background-color: ${({ variant }) => variant === "primary" ? "#3b82f6" : "white"}; color: ${({ variant }) => (variant === "primary" ? "white" : "#213547")}; `;

First, we add an uncontrolled file input, which we configure using the register function returned from the useForm hook. Additionally, we make the input required and implement validation for this requirement.

Alternatively, you could create a drag-and-drop file upload component to enhance the file upload experience. However, for this tutorial, we will stick to the basics and focus on the multipart form data aspect.

Handling File Inputs

By default, React Hook Form does not capture file input values due to their unique behavior compared to regular text inputs. This is because, for file input, the uploaded files are stored on the input itself as a FileList object — a list of the uploaded files. To access the actual uploaded file in our case, we need to retrieve it from the input's files property: event.target.files[0]. Since React Hook Form saves the entire file list array to its state, we have a few options to obtain it:

  • Convert the file input to a controlled input and modify its onChange method.
  • Retrieve the file from the form data when submitting the form.

Let's examine both the controlled and uncontrolled options.

Using controlled input

To transform the file input into a controlled one, we need to wrap it in the Controller component, just as we did with the NumberInput.

jsx
return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name}> <Input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Picture" error={errors.picture}> <Controller control={control} name={"picture"} rules={{ required: "Recipe picture is required" }} render={({ field: { value, onChange, ...field } }) => { return ( <Input {...field} value={value?.fileName} onChange={(event) => { onChange(event.target.files[0]); }} type="file" id="picture" /> ); }} /> </Field> <Field label="Description" error={errors.description}> // ... </Field> <Field label="Servings" error={errors.amount} htmlFor="amount"> // ... </Field> </FieldSet> <FieldSet label="Ingredients">// ...</FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> );
jsx
return ( <Container> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name}> <Input {...register("name", { required: "Recipe name is required" })} type="text" id="name" /> </Field> <Field label="Picture" error={errors.picture}> <Controller control={control} name={"picture"} rules={{ required: "Recipe picture is required" }} render={({ field: { value, onChange, ...field } }) => { return ( <Input {...field} value={value?.fileName} onChange={(event) => { onChange(event.target.files[0]); }} type="file" id="picture" /> ); }} /> </Field> <Field label="Description" error={errors.description}> // ... </Field> <Field label="Servings" error={errors.amount} htmlFor="amount"> // ... </Field> </FieldSet> <FieldSet label="Ingredients">// ...</FieldSet> <Field> <Button variant="primary">Save</Button> </Field> </form> </Container> );

By using the Controller component, we can easily extract the onChange function and value of a field from the render argument. We then use the extracted onChange function to set the uploaded file to the form via event.target.files[0]. Additionally, we set value?.fileName as the input's value to avoid a potential error:

shell
Uncaught DOMException: Failed to set the 'value' property on 'HTMLInputElement': This input element accepts a filename, which may only be programmatically set to the empty string.
shell
Uncaught DOMException: Failed to set the 'value' property on 'HTMLInputElement': This input element accepts a filename, which may only be programmatically set to the empty string.

This error occurs because the file input is always an uncontrolled input in React. Its value can only be set by the user.

Submitting Multipart Form Data

The second approach, which we'll consider as preferred in this case, is to extract the file from the file list before submitting the data. To submit the form, we'll modify the saveData callback in the App.js.

Currently, the saveData callback only logs the collected form data to the console. We will update this function to prepare the form data and to make a fetch API call to the "create recipe" endpoint. It is important to note that the endpoint itself does not have a backend and is used for illustration purposes only.

Considering the form data for our recipe form, which includes text fields and a file upload, we will use the FormData API. This allows us to create key-value pairs for the form fields and transmit them as multipart form data.

jsx
// App.js const submitForm = (data) => { const formData = new FormData(); formData.append("files", data.picture[0]); data = { ...data, picture: data.picture[0].name }; formData.append("recipe", JSON.stringify(data)); return fetch("/api/recipes/create", { method: "POST", body: formData, }).then((response) => { if (response.ok) { // Handle successful upload } else { // Handle error } }); };
jsx
// App.js const submitForm = (data) => { const formData = new FormData(); formData.append("files", data.picture[0]); data = { ...data, picture: data.picture[0].name }; formData.append("recipe", JSON.stringify(data)); return fetch("/api/recipes/create", { method: "POST", body: formData, }).then((response) => { if (response.ok) { // Handle successful upload } else { // Handle error } }); };

Typically, to construct the FormData object, we would iterate through all the fields and append them individually. However, as we have only one image field (the picture field), we can directly extract the file and add it to the files property of the FormData object. Next, we modify the data received from the recipe form to store the recipe name rather than the file, and then serialize the entire data within the recipe property. Consequently, our API payload FormData object will contain two fields: files and recipe.

It's worth noting that we use data.picture[0] to access the image file. However, if you went with the controlled component approach, the picture will be a file object rather than a file list, making the index unnecessary for access.

Another important note is that when using the FormData API, you should not explicitly set the Content-Type header in your API request. The browser will automatically set the appropriate Content-Type header, including the necessary boundary parameter.

Testing file upload with React Testing Library

Since we added a new form field to our form and made it required, the tests we added in an earlier tutorial no longer work and need to be updated.

First, in the validation test, we have to increase the number of displayed error messages to account for the additional field.

jsx
it("should validate form fields", async () => { const mockSave = jest.fn(); const { user } = setup(<RecipeForm saveData={mockSave} />); await user.type( screen.getByRole("textbox", { name: /description/i }), "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", ); await user.type(screen.getByRole("spinbutton", { name: /servings/i }), "110"); await user.click(screen.getByRole("button", { name: /save/i })); expect(screen.getAllByRole("alert")).toHaveLength(4); expect(mockSave).not.toBeCalled(); });
jsx
it("should validate form fields", async () => { const mockSave = jest.fn(); const { user } = setup(<RecipeForm saveData={mockSave} />); await user.type( screen.getByRole("textbox", { name: /description/i }), "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", ); await user.type(screen.getByRole("spinbutton", { name: /servings/i }), "110"); await user.click(screen.getByRole("button", { name: /save/i })); expect(screen.getAllByRole("alert")).toHaveLength(4); expect(mockSave).not.toBeCalled(); });

We are using getByRole to query elements, which is considered the best practice for querying elements in React Testing Library.

Finally, we need to test the file upload scenario in the submit flow test. Testing file uploads is a bit different from testing other input elements since the file upload input (input type="file") doesn't have an explicit ARIA role, and the uploaded file is an instance of the FileList object, which is difficult to test.

Instead, we'll try a different approach:

  1. Get the file input element with the getByLabelText query, which is usually the next best way to query elements after *ByRole queries.
  2. Create a mock file using the File constructor.
  3. Upload the mock file using the upload method from userEvent.
  4. Verify that the input.files property contains the file we uploaded.
jsx
it("should submit correct form data", async () => { const mockSave = jest.fn(); const { user } = setup(<RecipeForm saveData={mockSave} />); await user.type( screen.getByRole("textbox", { name: /name/i }), "Test recipe", ); await user.type( screen.getByRole("textbox", { name: /description/i }), "Delicious recipe", ); await user.type(screen.getByRole("spinbutton", { name: /servings/i }), "4"); await user.click(screen.getByRole("button", { name: /add ingredient/i })); await user.type( screen.getAllByRole("textbox", { name: /name/i })[1], "Flour", ); await user.type(screen.getByRole("textbox", { name: /amount/i }), "100 gr"); // Test image upload const input = screen.getByLabelText("Picture"); const file = new File(["File contents"], "recipeImage.png", { type: "image/png", }); await userEvent.upload(input, file); expect(input.files[0]).toBe(file); expect(input.files.item(0)).toBe(file); expect(input.files).toHaveLength(1); await user.click(screen.getByRole("button", { name: /save/i })); expect(mockSave).toHaveBeenCalledWith( expect.objectContaining({ name: "Test recipe", description: "Delicious recipe", amount: 4, ingredients: [{ name: "Flour", amount: "100 gr" }], }), ); });
jsx
it("should submit correct form data", async () => { const mockSave = jest.fn(); const { user } = setup(<RecipeForm saveData={mockSave} />); await user.type( screen.getByRole("textbox", { name: /name/i }), "Test recipe", ); await user.type( screen.getByRole("textbox", { name: /description/i }), "Delicious recipe", ); await user.type(screen.getByRole("spinbutton", { name: /servings/i }), "4"); await user.click(screen.getByRole("button", { name: /add ingredient/i })); await user.type( screen.getAllByRole("textbox", { name: /name/i })[1], "Flour", ); await user.type(screen.getByRole("textbox", { name: /amount/i }), "100 gr"); // Test image upload const input = screen.getByLabelText("Picture"); const file = new File(["File contents"], "recipeImage.png", { type: "image/png", }); await userEvent.upload(input, file); expect(input.files[0]).toBe(file); expect(input.files.item(0)).toBe(file); expect(input.files).toHaveLength(1); await user.click(screen.getByRole("button", { name: /save/i })); expect(mockSave).toHaveBeenCalledWith( expect.objectContaining({ name: "Test recipe", description: "Delicious recipe", amount: 4, ingredients: [{ name: "Flour", amount: "100 gr" }], }), ); });

Note that we also change the assertion for the mockSave payload to use expect.objectContaining, since we're testing for a subset of the form data now, excluding the picture field we tested earlier.

With this, we have a complete test suite for testing our recipe form, including file upload scenario tests.

Conclusion

Working with multipart form data can be a bit tricky, but React Hook Form simplifies things. With the FormData API and the useForm hook, you can easily create forms that allow users to upload files and other binary data.

In this post, we discussed how to use React Hook Form with multipart form data. We added an input field that allowed users to upload pictures to the recipes. We used the FormData API to create a new FormData object and append the recipe data to it. We then used the fetch API to make a POST request to a server endpoint. Finally, we expanded our React Testing Library unit tests to account for the file upload scenario.

With this, we have a fully functional file upload system within our recipe form, built using React Hook Form.

References and resources