react-hook-form-file-upload
v0.9.2
Published
file uploads for react-hook-form
Readme
react-hook-form-file-upload
This library for React Hook Form is designed to simplify
handling file uploads for your forms. Handling files in your form is annoying for many
reasons, but the biggest one is that file uploads are a completely different category
of data compared to everything else in a web form. react-hook-form-file-upload
will help you tame file uploads, allowing you to treat uploaded files as just another
text field in your form.
How does it work?
When a user submits form data, the data is typically validated on the server and then stored in a database. However, when a user uploads a file, that file needs to be stored somewhere else: it's a bad idea to store files in your database! Most often, that "somewhere else" is an object storage system like Amazon S3. You give the file to S3, and then store a reference to that file in your database; usually, the reference is name of the file. That way, you can always get the file back from your object store by asking for it by reference.
But if this reference is what you store in your database, then conceptually, it's just
the same as form data. What if the user didn't give you a file at all, and instead gave you
the reference for where that file is already stored? That simplifies the situation
dramatically -- and it is the core concept behind react-hook-form-file-upload.
The FileUploadController component in this library effectively allows you to treat
file uploads as string references, from the perspective of React Hook Form.
However, this component is just one piece of the puzzle: in order for this to work
correctly, you need to set up your backend and your object store correctly, as well.
Example
Backend code:
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const BUCKET = "my-upload-bucket";
// This code uses the express web framework (https://expressjs.com/),
// but you can use whatever backend framework or language you want
app.use("/signed-url", async (req, res) => {
const { ACCESS_KEY, SECRET_KEY } = process.env;
const key = req.body.key;
if (!key) {
return res.status(400).json({ error: "missing key param" });
}
const client = new S3Client({
credentials: {
accessKeyId: ACCESS_KEY,
secretAccessKey: SECRET_KEY,
},
});
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
});
// This signed URL expires in one hour, but the duration is up to you
const url = await getSignedUrl(client, command, { expiresIn: 3600 });
return res.json({ url });
});Frontend code:
import { useState } from "react";
import { useForm } from "react-hook-form";
import {
FileUploadController,
createFileUploader,
} from "react-hook-form-file-upload";
const BUCKET_WEB_URL =
"http://my-upload-bucket.s3-website-us-east-1.amazonaws.com/";
const ImageForm = () => {
const { handleSubmit, control } = useForm();
const onSubmit = handleSubmit((data) => {
console.log(data);
// do whatever you want with the form data
});
const [isUploading, setIsUploading] = useState(false);
const upload = createFileUploader({
presignUrl: "/signed-url",
});
return (
<form onSubmit={onSubmit}>
<FileUploadController
name="image"
control={control}
uploadFiles={(files) => {
setIsUploading(true);
try {
return upload(files);
} finally {
setIsUploading(false);
}
}}
render={({ value, field, remove, select }) => (
<div className="upload-wrapper vertical">
<input {...field} accept="image/*" className="hidden" />
{isUploading ? "Uploading file..." : null}
{value ? (
<div>
<img
src={`${BUCKET_WEB_URL}${value}`}
className="image-preview"
/>
<div>
<button type="button" onClick={remove}>
remove
</button>
<button type="button" onClick={select} disabled={isUploading}>
replace
</button>
</div>
</div>
) : (
<button type="button" onClick={select} disabled={isUploading}>
select image file
</button>
)}
</div>
)}
/>
</form>
);
};You'll also need to configure Cross-Origin Resource Sharing (CORS) on your bucket, so that your users are allowed to upload files directly to the bucket using the presigned URL from your backend API. If you want users to see a preview image of the file they have uploaded, you'll also need to configure the bucket to serve files like a website.
But in terms of the code you need to write, that's it!
react-hook-form-file-upload allows you to focus on designing your UI,
not the logic of handling file uploads.
For a more complete, runnable example of how to use this library,
including error handling and scripts for how configure your bucket correctly,
see the example directory in this repo.
API
<FileUploadController />
This is a
React Hook Form Controller component
that is designed for handling file uploads. It inherits all the props from
the Controller component described in the RHF documentation, and adds a few more.
| Name | Type | Description |
| --------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| name | FieldPath | Required. Unique name of your file upload input. |
| control | Control | control object from invoking useForm. Optional when using FormProvider. |
| render | Function | A required function that returns a React element and provides the ability to attach events and value into the component. See below for how this is called. |
| validateFiles | Function | An optional function that accepts a FileList object before the files are uploaded. If the function returns true or undefined, the field is valid. If the function returns false or a string, it is treated as an error, and the files are not uploaded. If the form is using FormProvider, the error is also set on the field using setError. |
| uploadFiles | Function | A required function that accepts a FileList object. It must upload all files in the FileList to your object storage, and return a Promise that resolves to a string value, which should be a reference to the locations of those files in your object storage. The resolved value must be a single string, not a list of strings, even if the FileList contains multiple files; you may join multiple string references together using some kind of delimiter, like , or \|. If you are using presigned URLs, we suggest using the createFileUploader helper function to create this function for you. |
| deleteReference | Function | An optional function that accepts a string reference to an uploaded file in your object storage. This function is called when the user indicates that the file(s) they uploaded previously should be replaced with different file(s); this gives you the opportunity to delete the discarded file(s) from your object storage. |
The required render function is called with an object that has five keys:
| Key | Type | Description |
| ---------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| field | Object | Almost the same as the field object from useController. The only differences are that type: "file" is added, and the value field in this object is always undefined. You should use this field object to create your <input> tag, like this: <input {...field} /> |
| fieldState | Object | Identical to the fieldState object from useController. |
| formState | Object | Identical to the formState object from useController. |
| value | string | The current string value of this field in React Hook Form. This is a reference to a file uploaded to your object storage. Normally, this would be exposed in the field object, but since browsers do not allow setting the value attribute on <input type="file"> tags, field.value is always undefined to avoid browser security warnings. |
| select | Function | A function that triggers a click on your <input {...field} /> tag, which should open the browser's file selection dialog. |
| remove | Function | A function that resets the value of this field to "" (the empty string) in React Hook Form. It will also clear the selection in your <input {...field} /> tag, and call the deleteReference function on the previous reference (if defined). |
createFileUploader
If you use
presigned URLs,
you may use this helper function to simplify the creation of an uploadFiles function.
It accepts one argument, which is an object with the following keys:
| Key | Type | Description |
| ------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| presignUrl | string | Required. This is the URL to your backend API that generates presigned URLs. |
| presignOptions | RequestInit \| ((file: File, index: number) => RequestInit) | If the fetch request for a presigned URL requires additional options, such as setting authentication headers, you may pass them here. You can either pass the RequestInit object directly, or pass a function that generates it based on the file and index from the FileList. |
| key | string \| ((file: File, index: number) => string) | A function to use for generating a key name from a selected file to upload. The key determines where this file will be uploaded in your object storage. If no value is specified, the default is (file) => file.name. You may want to use a UUID generator to avoid conflicts where two users upload two different files that have the same name. Alternatively, you may specify a static string here -- although that is not recommended, since it will cause all file uploads to conflict with each other. |
| keySeparator | string | The separator to use when calling join() on the string array resulting from uploading multiple files. This is only relevant if you specify the multiple attribute on your <input {...files}> tag. |
| uploadEventHandlers | UploadEventHandlers \| ((file: File, index: number) => UploadEventHandlers) | An object of event handlers to add to the XMLHttpRequest.upload object. You may also pass a function that generates this object based on the file and index from the FileList. This is most useful for the progress event, which can be used for displaying an upload progress bar. |
In order to use createFileUploader, your backend must expose an API that returns a
presigned URL; the URL to that API is passed to the presignUrl key.
This backend API must have the following characteristics:
- It responds to a HTTP POST request
- It expects a JSON-encoded POST body, which must be an object with a
keyvalue for intended upload location within your object storage system - It returns a JSON-encoded response, which must be an object with a
urlvalue for the generated presigned URL
Note that using createFileUploader is completely optional. If you want to
upload files to your object storage system in a different way, that's fine!
You can write your own uploadFiles function for FileUploadController
without using createFileUploader.
FAQ
Can I use a different object storage system, instead of Amazon S3?
Of course! Any system with S3-compatible APIs will work exactly the same way. Backblaze B2, Cloudflare R2, and Wasabi are all good options. You'll just need to modify your backend to return a presigned URL for the object storage system that you want to use.
Can I use a system that doesn't use S3-compatible APIs?
Yes! But you'll probably need to write your own uploadFiles function,
instead of using the createFileUploader() helper that comes with
react-hook-form-file-upload. The uploadFiles function needs to
accept a FileList argument, upload all the files from that FileList,
and return a Promise that resolves to a string reference.
As long as you can do that, you're all set!
Can I change how the UI is displayed?
Hey, it's your website, make it look however you want.
The FileUploadController component uses a render prop,
specifically so that you have full control over how the file upload
UI is displayed.
The only requirement is that you must render an
<input {...field} /> tag, as shown in the example code above;
without that, the browser's file picker won't trigger
when you call the select() function. But if you don't like
how that input looks, you're free to use CSS to hide it!
display: none is useful for that.
How do get upload progress information, so I can display a progress bar?
You'll need to use the
progress event for XMLHttpRequest objects
to get progress information on your upload(s).
Unfortunately, there is no way of getting progress information on
fetch requests, so using XMLHttpRequest is the only option for now.
If you are using the createFileUploader helper function, you can
use the uploadEventHandlers to attach your progress event handler.
For example:
const [uploadTotal, setUploadTotal] = useState(0);
const [uploadLoaded, setUploadLoaded] = useState(0);
const upload = createFileUploader({
presignUrl: "/signed-url",
uploadEventHandlers: {
progress(event: ProgressEvent) {
if (event.lengthComputable) {
setUploadTotal(event.total);
setUploadLoaded(event.loaded);
}
},
},
});You can also take a look at the ProgressUpload.tsx file in the example/src directory.
Best Practices and Recommendations
Auto-Delete Uploaded Files
The whole point of this system is that users can upload files directly into your S3 bucket (using presigned URLs). However, storing files in S3 costs money, and users will often make mistakes, uploading the wrong file several times before they manage to upload the right file and submit the form. This mistaken uploads will continue to sit in your S3 bucket, incurring storage costs, until they are deleted.
To minimize costs, you can use two S3 buckets: one bucket for users to upload files into (including mistakes), and a second bucket that only your application can write into. Set a lifecycle configuration on the first bucket so that all uploaded files are automatically deleted after a certain length of time (one day, for example). When a user submits your form, that form submission will include a reference to the file that they uploaded to the first bucket. As part of handling the form submission, your backend should move that uploaded file from the first bucket to the second bucket, to ensure that it will not get automatically deleted by the lifecycle configuration of the first bucket.
Tightly Scoped CORS
Cross-Origin Resource Sharing (CORS)
is complicated, and it's tempting to use Access-Control-Allow-Origin: *
to make uploads work for any website, all the time. However, this means that
any website can upload files into your bucket; an attacker could use this
to dramatically increase your S3 costs by continuously uploading
many large files to your bucket.
Instead, it's better to keep your CORS policy tightly scoped, so that only the domains you control are allowed to upload files to your bucket. A wildcard policy is insecure, so specify the domain (or domains) that you want to allow. If you don't know all the domains yet, that's OK! You can always change your CORS policy in the future to add more domains.
