React Hook Form: Working with Multipart Form Data and File Uploads
Updated on · 7 min read|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.
jsximport 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")}; `;
jsximport 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
.
jsxreturn ( <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> );
jsxreturn ( <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:
shellUncaught 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.
shellUncaught 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.
jsxit("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(); });
jsxit("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:
- Get the file input element with the
getByLabelText
query, which is usually the next best way to query elements after*ByRole
queries. - Create a mock file using the File constructor.
- Upload the mock file using the
upload
method fromuserEvent
. - Verify that the
input.files
property contains the file we uploaded.
jsxit("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" }], }), ); });
jsxit("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
- Build Dynamic Forms with React Hook Form
- Build a Multistep Form With React Hook Form
- Creating Accessible Form Components with React
- Drag-and-Drop File Upload Component with React and TypeScript
- Form Validation with React Hook Form
- GitHub repository with the code for the tutorial
- Improving React Testing Library Tests
- Jest:
expect.objectContaining
documentation - MDN: Fetch API
- MDN: File constructor API
- MDN: FormDataAPI
- Managing Forms with React Hook Form
- React Hook Form
- React Testing Library
- React docs: File Input Tag
- Simplifying Form Rendering In React with Field Component Abstraction
- Testing React Hook Form With React Testing Library
- Understanding Controlled vs Uncontrolled Components In React
- w3.org: HTML-ARIA