Hello guys, today we are going to learn how we can use react-dropzone with react-hook-form (a hook based React library for building forms) for handling file input, so let's get started.
Note: I'm using tailwindcss so you may ignore all the class names you see in this tutorial and use you own.
Now before we begin, make sure you've installed both the required dependencies.
Step 1) Create a custom FileInput Component.
// components/FormComponents/FileInput.tsx
import React, { FC, useCallback, useEffect } from 'react'
import { DropzoneOptions, useDropzone } from 'react-dropzone'
import { useFormContext } from 'react-hook-form'
interface IFileInputProps
extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
label?: string
}
const FileInput: FC<IFileInputProps> = (props) => {
const { name, label = name } = props
const {
register,
unregister,
setValue,
watch,
} = useFormContext()
const files: File[] = watch(name)
const onDrop = useCallback<DropzoneOptions['onDrop']>(
(droppedFiles) => {
setValue(name, droppedFiles, { shouldValidate: true })
},
[setValue, name],
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: props.accept,
})
useEffect(() => {
register(name)
return () => {
unregister(name)
}
}, [register, unregister, name])
return (
<>
<label
className='block text-gray-700 text-sm font-bold mb-2 capitalize'
htmlFor={name}
>
{label}
</label>
<div {...getRootProps()}>
<input
{...props}
className='shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline'
id={name}
{...getInputProps()}
/>
<div
className={
'w-full p-2 border border-dashed border-gray-900 ' +
(isDragActive ? 'bg-gray-400' : 'bg-gray-200')
}
>
<p className='text-center my-2'>Drop the files here ...</p>
{/* Optionally you may display a preview of the file(s) */}
{!!files?.length && (
<div className='grid gap-1 grid-cols-4 mt-2'>
{files.map((file) => {
return (
<div key={file.name}>
<img
src={URL.createObjectURL(file)}
alt={file.name}
style={{ width: '100px', height: '100px' }}
/>
</div>
)
})}
</div>
)}
</div>
</div>
</>
)
}
export default FileInput
Note: This is just an example to illustrate the concept, hence I've skipped error handling, and validations, but you may do as you see fit.
Step 2) Using this component in a form.
// components/Forms/ProductForm.tsx
import React from 'react'
import { FormProvider, useForm } from 'react-hook-form'
import Input from 'components/FormComponents/Input'
import FileInput from 'components/FormComponents/FileInput'
export const ProductForm: React.FC = () => {
const methods = useForm({
mode: 'onBlur',
})
const onSubmit = methods.handleSubmit((values) => {
console.log('values', values)
// Implement your own form submission logic here.
})
return (
<FormProvider {...methods}>
<form onSubmit={onSubmit}>
<div className='mb-4'>
<Input name='name' />
</div>
<div className='mb-4'>
<Input name='description' />
</div>
<div className='mb-4'>
<Input name='price' type='number' />
</div>
<div className='mb-4'>
<Input name='discount' type='number' />
</div>
<div className='mb-4'>
<FileInput
accept='image/png, image/jpg, image/jpeg, image/gif'
multiple
name='images'
/>
</div>
<div className='mb-4'>
<button className='w-full bg-primary'>
Create
</button>
</div>
</form>
</FormProvider>
)
}
And here is the Input Component used above, just in case you want to take a sneak peek.
// components/FormComponents/Input.tsx
import React from 'react'
import { useFormContext, ValidationRules, FieldError } from 'react-hook-form'
import { DeepMap } from 'react-hook-form/dist/types/utils'
import { FaInfoCircle } from 'react-icons/fa'
export const get = (errors: DeepMap<Record<string, any>, FieldError>, name: string): FieldError => {
const result = name.split('.').reduce((prev, cur) => prev?.[cur], errors)
return result
}
export interface IInputProps
extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
label?: string
validate?: ValidationRules
}
const Input: React.FC<IInputProps> = (props) => {
const { name, label = name, validate } = props
const { errors, register } = useFormContext()
const errorMessage = get(errors, name)?.message
const ref = register(validate)
return (
<div>
<label
className={`block ${
errorMessage ? 'text-red-600' : 'text-gray-700'
} text-sm font-bold mb-2 capitalize`}
htmlFor={name}
>
{label}
</label>
<input
{...props}
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none ${
errorMessage ? 'border-red-600 focus:shadow-red bg-red-200' : 'focus:shadow-outline'
}`}
id={name}
ref={ref}
/>
{errorMessage && (
<p className='mt-2 text-red-600 font-medium text-xs italic'>
<FaInfoCircle className='mr-1' /> {errorMessage}
</p>
)}
</div>
)
}
export default Input
And You're Done
Now you can drag-n-drop your images into the dropzone container, or click the container to select images from the file chooser. And that's it, for the most part, Enjoy.
Bonus Tip- for image and media-centric web applications.
Now let's take a look at what's happening in the above GIF.
- Initially, we see an empty box.
- The user drags n drop 3 image files, which is immediately displayed inside the box.
- The user again drops 1 more image file in the box, which is again immediately displayed inside the box.
- And lastly, the user again drops the same 1 image file which he did in the previous step, and nothing happens.
Now there are 2 things to notice here:-
- Dropping files the second time preserves existing ones along with new file(s), which is not the default behaviour of
<input type='file' />
or react-dropzone. - Dropping a file that already exists does not affect as it automatically gets filtered out as a duplicate.
Let's see how we can incorporate these feature in the FileInput component
// components/FormComponents/FileInput.tsx
import React, { FC, useCallback, useEffect } from 'react'
import { DropzoneOptions, useDropzone } from 'react-dropzone'
import { useFormContext } from 'react-hook-form'
interface IFileInputProps
extends React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {
label?: string
mode?: 'update' | 'append'
}
const FileInput: FC<IFileInputProps> = (props) => {
const { name, label = name, mode = 'update' } = props
const {
register,
unregister,
setValue,
watch,
} = useFormContext()
const files: File[] = watch(name)
const onDrop = useCallback<DropzoneOptions['onDrop']>(
(droppedFiles) => {
/*
This is where the magic is happening.
Depending upon the mode we are replacing old files with new one,
or appending new files into the old ones, and also filtering out the duplicate files.
*/
let newFiles = mode === 'update' ? droppedFiles : [...(files || []), ...droppedFiles]
if (mode === 'append') {
newFiles = newFiles.reduce((prev, file) => {
const fo = Object.entries(file)
if (
prev.find((e: File) => {
const eo = Object.entries(e)
return eo.every(
([key, value], index) => key === fo[index][0] && value === fo[index][1],
)
})
) {
return prev
} else {
return [...prev, file]
}
}, [])
}
// End Magic.
setValue(name, newFiles, { shouldValidate: true })
},
[setValue, name, mode, files],
)
// ---- no changes here, same code as above ----
}
export default FileInput
Usage of append mode
<FileInput
accept='image/png, image/jpg, image/jpeg, image/gif'
multiple
name='images'
mode='append'
/>
And that's it you're ready to go.... enjoy.
Comment Down below, which one of you would like to see the file removal feature, and I'm might make an additional post with this one about how you can provide an option where the user can remove one or more of the selected files/images while keeping the others. :)
Top comments (10)
Man, you have just saved my day. Thanks! (Also great thing you did it in TypeScript ❤)
Also I'll try to get it done using refs instead of setValue. I'll tell you how goes my experiment 😁
I want to see how you did it with ref
Sure 👍
Finally made myself with a little bit of time, thanks again ;)
Thank you for sharing, but i didn't understand a line from your post because of TypeScript :(
Thanks for using typescript. Though getting quite a few type errors to work through.
Yeah, there's been a number of updates since I posted this.
I'll make an update as soon as possible.
Good to know it helped you. 👍
Thanks Vibhanshu, you made my day 🤗
Great article! Maybe adding Typescript into tutorial makes it bit harder to understand the real solution. 80% of the time went into understanding the types.
Thanks!