Кастомний хук drag-and-drop: реалізація, валідація, прев'ю

Кожен зайвий клік у формі завантаження файлів знижує конверсію та уповільнює роботу співробітників. Ми розробляємо кастомний drag-and-drop хук на TypeScript з валідацією MIME, прев'ю та прогрес-баром, який робить процес перетягування файлів інтуїтивним і швидким. Наша команда реалізує проєкт під ключ — від концепції до підтримки, забезпечуючи надійне рішення, яке масштабується разом із вашим бізнесом.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Послуги, які ми пропонуємо
Показано 1 з 1Усі 2062 послуг
Кастомний хук drag-and-drop: реалізація, валідація, прев'ю
Середній
від 1 дня до 3 днів
Часті запитання

Кожен зайвий клік у формі завантаження файлів знижує конверсію. Менеджеру потрібно завантажити 50 документів у CRM — 50 разів відкрити діалог вибору. Drag-and-drop вирішує проблему: користувач перетягує файли мишею, і процес триває секунди. Ми реалізуємо кастомну drag-and-drop завантаження: TypeScript-хук з валідацією MIME, глобальний дроп, прев'ю та прогрес-бар. Економія часу — до 60%, зниження помилок введення — на 40%. Досвід десятків проєктів із файловими менеджерами підтверджує ефективність.

Як реалізувати drag-and-drop завантаження з нуля?

Drag-and-drop зменшує кількість дій користувача в 2–3 рази порівняно з класичним вибором. Візуальний зворотний зв'язок (підсвічування зони, іконки) дає зрозуміти, що файли приймаються, а попередній перегляд до відправки знижує ризик помилок. Це стандарт для сучасних веб-додатків — Google Docs, Trello, Notion використовують його повсюдно. Згідно з MDN Drag and Drop API, нативний DnD API підтримується всіма сучасними браузерами.

Cross-browser поведінка

Події dragenter, dragover, dragleave, drop працюють неідеально у всіх браузерах. Типова помилка — dragleave спрацьовує при вході на дочірній елемент. Ми використовуємо e.currentTarget.contains(e.relatedTarget) для перевірки.

Валідація MIME-типів

Потрібно відсіювати небажані формати до відправки. Фільтр за шаблоном image/* або точному MIME. У коді нижче показана гнучка перевірка з підтримкою wildcard.

Як ми це робимо: кастомний хук useDragAndDrop

Розробляємо хук на TypeScript, який повертає пропси для зони скидання та стан isDragOver / isDragActive. Підтримуємо кастомний accept-фільтр і відключення.

// hooks/useDragAndDrop.ts
import { useState, useCallback, DragEvent } from 'react'

interface UseDragAndDropOptions {
  onDrop: (files: File[]) => void
  accept?: string[] // MIME-типи: ['image/jpeg', 'image/png']
  disabled?: boolean
}

export function useDragAndDrop({ onDrop, accept, disabled }: UseDragAndDropOptions) {
  const [isDragOver, setIsDragOver] = useState(false)
  const [isDragActive, setIsDragActive] = useState(false)

  const handleDragEnter = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    if (disabled) return
    setIsDragActive(true)
    if (e.dataTransfer.items?.length > 0) {
      setIsDragOver(true)
    }
  }, [disabled])

  const handleDragLeave = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    if (e.currentTarget.contains(e.relatedTarget as Node)) return
    setIsDragOver(false)
    setIsDragActive(false)
  }, [])

  const handleDragOver = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.dataTransfer.dropEffect = 'copy'
  }, [])

  const handleDrop = useCallback((e: DragEvent) => {
    e.preventDefault()
    e.stopPropagation()
    setIsDragOver(false)
    setIsDragActive(false)
    if (disabled) return
    const droppedFiles = Array.from(e.dataTransfer.files)
    const filtered = accept
      ? droppedFiles.filter(f =>
          accept.some(mime => {
            if (mime.endsWith('/*')) {
              return f.type.startsWith(mime.replace('/*', '/'))
            }
            return f.type === mime
          })
        )
      : droppedFiles
    if (filtered.length > 0) {
      onDrop(filtered)
    }
  }, [onDrop, accept, disabled])

  return {
    isDragOver,
    isDragActive,
    dropZoneProps: {
      onDragEnter: handleDragEnter,
      onDragLeave: handleDragLeave,
      onDragOver: handleDragOver,
      onDrop: handleDrop,
    },
  }
}

Компонент DropZone: візуальний зворотний зв'язок і клік

Інкапсулюємо хук в UI-компонент з aria-атрибутами для доступності. При перетягуванні показуємо overlay «Відпустіть для завантаження». Якщо користувач клікає — відкриваємо системний діалог.

// components/DropZone.tsx
import { useRef } from 'react'
import { useDragAndDrop } from '@/hooks/useDragAndDrop'
import { cn } from '@/lib/utils'

interface DropZoneProps {
  onFiles: (files: File[]) => void
  accept?: string[]
  maxFiles?: number
  disabled?: boolean
  children?: React.ReactNode
}

export function DropZone({
  onFiles,
  accept,
  maxFiles,
  disabled,
  children,
}: DropZoneProps) {
  const inputRef = useRef<HTMLInputElement>(null)
  const { isDragOver, isDragActive, dropZoneProps } = useDragAndDrop({
    onDrop: onFiles,
    accept,
    disabled,
  })

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      onFiles(Array.from(e.target.files).slice(0, maxFiles))
      e.target.value = ''
    }
  }

  return (
    <div
      {...dropZoneProps}
      onClick={() => !disabled && inputRef.current?.click()}
      role="button"
      tabIndex={disabled ? -1 : 0}
      aria-disabled={disabled}
      onKeyDown={e => e.key === 'Enter' && !disabled && inputRef.current?.click()}
      className={cn(
        'relative border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer',
        'focus:outline-none focus:ring-2 focus:ring-primary',
        isDragOver && 'border-primary bg-primary/5',
        isDragActive && 'border-primary',
        !isDragOver && 'border-muted-foreground/30 hover:border-muted-foreground/60',
        disabled && 'opacity-50 cursor-not-allowed',
      )}
    >
      {isDragOver && (
        <div className="absolute inset-0 flex items-center justify-center rounded-lg bg-primary/10">
          <p className="text-lg font-medium text-primary">Відпустіть для завантаження</p>
        </div>
      )}
      {children ?? (
        <div className="flex flex-col items-center gap-3 pointer-events-none">
          <UploadIcon className="w-10 h-10 text-muted-foreground" />
          <div>
            <p className="font-medium">Перетягніть файли або натисніть для вибору</p>
            <p className="text-sm text-muted-foreground mt-1">
              {accept?.join(', ') ?? 'Будь-які файли'} · до {maxFiles ?? 10} файлів
            </p>
          </div>
        </div>
      )}
      <input
        ref={inputRef}
        type="file"
        multiple
        accept={accept?.join(',')}
        className="sr-only"
        onChange={handleInputChange}
        disabled={disabled}
        aria-label="Вибір файлів"
      />
    </div>
  )
}

Прев'ю зображень до завантаження

Показуємо мініатюру одразу після вибору файлу, не чекаючи відправки на сервер. Використовуємо URL.createObjectURL і звільняємо пам'ять при демонтуванні.

// hooks/useFilePreview.ts
import { useState, useEffect } from 'react'

export function useFilePreview(file: File | null): string | null {
  const [preview, setPreview] = useState<string | null>(null)

  useEffect(() => {
    if (!file || !file.type.startsWith('image/')) {
      setPreview(null)
      return
    }

    const url = URL.createObjectURL(file)
    setPreview(url)

    return () => URL.revokeObjectURL(url)
  }, [file])

  return preview
}

Як реалізувати глобальний drag-and-drop?

Деякі додатки (поштові клієнти, файлові менеджери) приймають файли в будь-якому місці сторінки. Для цього використовуємо хук useGlobalDrop з лічильником подій dragenter/dragleave.

// hooks/useGlobalDrop.ts
import { useEffect, useState } from 'react'

export function useGlobalDrop(onDrop: (files: File[]) => void) {
  const [isActive, setIsActive] = useState(false)
  let dragCounter = 0

  useEffect(() => {
    const handleDragEnter = (e: DragEvent) => {
      if (!e.dataTransfer?.types.includes('Files')) return
      dragCounter++
      setIsActive(true)
    }

    const handleDragLeave = () => {
      dragCounter--
      if (dragCounter === 0) setIsActive(false)
    }

    const handleDrop = (e: DragEvent) => {
      e.preventDefault()
      dragCounter = 0
      setIsActive(false)
      if (e.dataTransfer?.files.length) {
        onDrop(Array.from(e.dataTransfer.files))
      }
    }

    const handleDragOver = (e: DragEvent) => e.preventDefault()

    document.addEventListener('dragenter', handleDragEnter)
    document.addEventListener('dragleave', handleDragLeave)
    document.addEventListener('dragover', handleDragOver)
    document.addEventListener('drop', handleDrop)

    return () => {
      document.removeEventListener('dragenter', handleDragEnter)
      document.removeEventListener('dragleave', handleDragLeave)
      document.removeEventListener('dragover', handleDragOver)
      document.removeEventListener('drop', handleDrop)
    }
  }, [onDrop])

  return isActive
}

Глобальний оверлей додається за допомогою прапорця isActive: якщо true, показуємо напівпрозоре накладання на весь екран.

Як обробляти папки цілком?

Якщо браузер підтримує DataTransferItem.webkitGetAsEntry, ми рекурсивно обходимо вміст папки та збираємо всі файли, фільтруючи за MIME-типами. Для старих браузерів використовуємо повідомлення користувачу про необхідність вибрати файли вручну.

react-dropzone: коли потрібно швидко

Якщо часу на кастомну реалізацію немає, використовуємо бібліотеку react-dropzone. Вона покриває 90% кейсів: валідація, обмеження кількості файлів, розміру. Кастомний хук дає більше контролю над візуалом і поведінкою.

Критерій Кастомний хук react-dropzone
Гнучкість UI Повна Обмежена пропсами
Розмір бандла ~2 KB ~5 KB (в 2.5 рази більше)
Підтримка глобального дропа Вручну Не вбудована
Accessibility Потрібно додавати вручну Є базова

Типові помилки Drag and Drop API

Помилка Причина Рішення
dragleave спрацьовує при наведенні на дочірній елемент Подія спливає Перевірка e.currentTarget.contains(e.relatedTarget)
drop не спрацьовує Не викликано preventDefault в dragover Завжди викликати e.preventDefault() в dragover
Файли не відображаються в глобальному дропі Не перевірено тип Files в dragenter Перевіряти e.dataTransfer.types.includes('Files')
Налаштування Nginx для завантаження великих файлів
client_max_body_size 100M; proxy_request_buffering off; proxy_buffering off; 

Для серверів з обмеженням за часом додайте proxy_read_timeout 300s;.

Чому кастомний хук краще react-dropzone?

Кастомний хук зменшує розмір бандла в 2.5 рази порівняно з react-dropzone (2 KB проти 5 KB). Крім того, він дає повний контроль над анімаціями, оверлеями та підтримкою глобального дропа. Для складних проєктів з нестандартним UI це оптимальний вибір.

Процес роботи

  1. Аналітика — вивчаємо сценарії завантаження користувачів, визначаємо обмеження (макс. розмір, типи файлів).
  2. Проєктування — прототип взаємодії, вибір між кастомним хуком і бібліотекою.
  3. Реалізація — розробка компонентів, валідація, прев'ю, індикатор прогресу.
  4. Тестування — перевірка в Chrome, Firefox, Safari, Edge, мобільних браузерах.
  5. Деплой — налаштування Nginx/Cloudflare для великих файлів, інтеграція з об'єктним сховищем.

Терміни орієнтовно

  • Базовий компонент з кастомним хуком, прев'ю та прогрес-баром — 1.5–2 дні.
  • Розширена версія з глобальним дропом, сортуванням, retry, presigned URL — 3–4 дні.

Що входить в роботу

  • Вихідний код (TypeScript, React) з коментарями.
  • Документація по інтеграції.
  • Доступ до репозиторію.
  • Навчання команди замовника (1 година онлайн).
  • Гарантія 6 місяців на код.

Потрібна консультація? Зв'яжіться — ми підберемо оптимальне рішення та оцінимо ваш проєкт за 1 день.

Наші компетенції:

Останні роботи

  • Розробка сайту компанії B2B ADVANCE
    Розробка сайту компанії B2B ADVANCE
    1542
  • Розробка веб-додатків для компанії FEEDME
    Розробка веб-додатків для компанії FEEDME
    1394
  • Розробка веб-сайту для компанії БЕЛФІНГРУП
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    1081
  • Розробка інтернет магазину для компанії FURNORO
    Розробка інтернет магазину для компанії FURNORO
    1369
  • Розробка веб-додатків для компанії Enviok
    Розробка веб-додатків для компанії Enviok
    1142
  • Розробка веб-сайту для компанії ФІКСПЕР
    Розробка веб-сайту для компанії ФІКСПЕР
    1060