DEV Community

loading...

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

vibhanshu909 profile image vibhanshu pandey Updated on ・5 min read

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

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

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. :)

Discussion

pic
Editor guide
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 :(