DEV Community

vibhanshu pandey
vibhanshu pandey

Posted on • Updated on

How to use react-dropzone with react-hook-form

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And You're Done

How to use react-dropzone with react-hook-form

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
Enter fullscreen mode Exit fullscreen mode

Usage of append mode

<FileInput
  accept='image/png, image/jpg, image/jpeg, image/gif'
  multiple
  name='images'
  mode='append'
/>
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
khorne07 profile image
Khorne07 • Edited

Man, you have just saved my day. Thanks! (Also great thing you did it in TypeScript ❤)

Collapse
 
khorne07 profile image
Khorne07

Also I'll try to get it done using refs instead of setValue. I'll tell you how goes my experiment 😁

Collapse
 
r11 profile image
Peter Jaffray

I want to see how you did it with ref

Collapse
 
vibhanshu909 profile image
vibhanshu pandey

Sure 👍

Collapse
 
barbgegrasse profile image
barbgegrasse

Finally made myself with a little bit of time, thanks again ;)

Collapse
 
barbgegrasse profile image
barbgegrasse

Thank you for sharing, but i didn't understand a line from your post because of TypeScript :(

Collapse
 
jdthegeek profile image
John Dennehy

Thanks for using typescript. Though getting quite a few type errors to work through.

Collapse
 
vibhanshu909 profile image
vibhanshu pandey

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. 👍

Collapse
 
irsooti profile image
Daniele

Thanks Vibhanshu, you made my day 🤗

Collapse
 
hirenchauhan2 profile image
Hiren Chauhan

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!