Back

Создание пользовательского компонента загрузки файлов для React

Создание пользовательского компонента загрузки файлов для React

Нативный file input работает, но выглядит непривлекательно, негибок и не предоставляет никакой обратной связи во время загрузки. Большинство разработчиков используют библиотеки вроде react-dropzone для решения этой проблемы. Но если вы хотите полного контроля — или просто меньше зависимостей — создание пользовательского компонента загрузки файлов для React не так сложно, если понимать ограничения.

Это руководство охватывает основы: доступный выбор файлов, опциональный функционал drag-and-drop, валидацию, предпросмотр, отслеживание прогресса и отмену загрузки. Всё с использованием современных функциональных компонентов и хуков.

Ключевые выводы

  • File inputs по своей природе являются неконтролируемыми в React из-за ограничений безопасности браузера, которые предотвращают программное присвоение значений
  • Всегда сочетайте клиентскую валидацию с серверной, поскольку MIME-типы и атрибут accept можно обойти
  • Используйте XMLHttpRequest вместо fetch для надёжного отслеживания прогресса загрузки
  • Отзывайте object URL, созданные для предпросмотра файлов, чтобы предотвратить утечки памяти
  • Drag-and-drop должен быть прогрессивным улучшением, а не заменой взаимодействия с клавиатурой и кликами

Почему file inputs остаются неконтролируемыми

Вот что сбивает с толку разработчиков React: вы не можете программно установить значение <input type="file">. Безопасность браузера это предотвращает. Это означает, что file inputs по своей природе неконтролируемые — React может читать из них, но никогда не может записывать в них.

Это важно и для Form Actions в React 19. Хотя useFormStatus может отслеживать состояние ожидания, Server Actions не предназначены для загрузки больших файлов. Вы всё равно будете обрабатывать файлы на клиентской стороне перед отправкой куда-либо.

Основа: доступный выбор файлов

Начните с самого input. Скройте его визуально, но сохраните доступность:

const FileUploader = () => {
  const inputRef = useRef<HTMLInputElement>(null)
  const [files, setFiles] = useState<File[]>([])

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files) {
      setFiles(Array.from(e.target.files))
    }
  }

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        id="file-upload"
        className="sr-only"
        onChange={handleChange}
        multiple
        aria-describedby="file-upload-help"
      />
      <label htmlFor="file-upload" className="upload-button">
        Select files
      </label>
      <span id="file-upload-help" className="sr-only">
        Accepted formats: images, PDFs up to 10MB
      </span>
    </div>
  )
}

Атрибут accept носит только рекомендательный характер — пользователи могут его обойти. Всегда проводите валидацию на сервере.

Добавление drag-and-drop как прогрессивного улучшения

Функционал drag-and-drop улучшает пользовательский опыт, но не является обязательным. Оставьте взаимодействие с клавиатурой и кликами в качестве основного пути:

const [isDragging, setIsDragging] = useState(false)

const handleDragOver = (e: React.DragEvent) => {
  e.preventDefault()
  setIsDragging(true)
}

const handleDragLeave = () => setIsDragging(false)

const handleDrop = (e: React.DragEvent) => {
  e.preventDefault()
  setIsDragging(false)
  
  const droppedFiles = Array.from(e.dataTransfer.files)
  setFiles(droppedFiles)
}

Примечание: DataTransferItem.getAsFileSystemHandle() предоставляет доступ к директориям в современных браузерах, но поддержка остаётся ограниченной. Придерживайтесь dataTransfer.files для широкой совместимости.

Клиентская валидация

Проводите валидацию перед загрузкой для улучшения пользовательского опыта:

const validateFile = (file: File): string | null => {
  const maxSize = 10 * 1024 * 1024 // 10MB
  const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
  
  if (file.size > maxSize) return 'File exceeds 10MB limit'
  if (!allowedTypes.includes(file.type)) return 'Invalid file type'
  return null
}

Помните: MIME-типы можно подделать. Серверная валидация обязательна.

Предпросмотр файлов с правильной очисткой

Для предпросмотра изображений URL.createObjectURL создаёт временные URL. Отзывайте их, чтобы предотвратить утечки памяти:

const [preview, setPreview] = useState<string | null>(null)

useEffect(() => {
  if (files[0]?.type.startsWith('image/')) {
    const url = URL.createObjectURL(files[0])
    setPreview(url)
    return () => URL.revokeObjectURL(url)
  }
}, [files])

Отслеживание прогресса и отмена загрузки

Вот неудобная правда: fetch не поддерживает надёжное отслеживание прогресса загрузки. Для реального отслеживания прогресса используйте XMLHttpRequest:

const [progress, setProgress] = useState(0)
const xhrRef = useRef<XMLHttpRequest | null>(null)

const upload = (file: File) => {
  const xhr = new XMLHttpRequest()
  xhrRef.current = xhr
  
  xhr.upload.onprogress = (e) => {
    if (e.lengthComputable) {
      setProgress(Math.round((e.loaded / e.total) * 100))
    }
  }
  
  const formData = new FormData()
  formData.append('file', file)
  
  xhr.open('POST', '/api/upload')
  xhr.send(formData)
}

const cancel = () => xhrRef.current?.abort()

Для больших файлов рассмотрите прямую загрузку в объектное хранилище (S3 presigned URLs) или подходы с разбиением на части/multipart. Отправка многогигабайтных файлов через сервер приложения редко заканчивается хорошо.

Состояния ошибок

Отслеживайте состояние загрузки явно:

type UploadState = 
  | { status: 'idle' }
  | { status: 'uploading'; progress: number }
  | { status: 'success'; url: string }
  | { status: 'error'; message: string }

Это дискриминируемое объединение делает рендеринг простым и типобезопасным.

Когда использовать библиотеку вместо собственного решения

Создание собственного пользовательского компонента загрузки файлов для React имеет смысл, когда вам нужно специфическое поведение или вы хотите минимизировать размер бандла. Библиотеки вроде react-dropzone обрабатывают граничные случаи, которые вы можете упустить — но они также добавляют зависимости и подходы, которые могут вам не подойти.

Основной паттерн прост: скрытый input, доступный label, опциональная зона drop, валидация и XHR для прогресса. Всё остальное — это доработка.

Заключение

Пользовательский компонент загрузки файлов даёт вам точный контроль над поведением, стилизацией и размером бандла. Ключевые ограничения просты: file inputs должны оставаться неконтролируемыми, клиентская валидация дополняет, но никогда не заменяет серверные проверки, а XMLHttpRequest остаётся надёжным выбором для отслеживания прогресса. Начните с доступных основ, добавьте drag-and-drop как опциональное улучшение и обрабатывайте очистку памяти для предпросмотра. Создавать ли собственное решение или использовать библиотеку — зависит от ваших конкретных потребностей, но теперь вы понимаете, что происходит под капотом в любом случае.

Часто задаваемые вопросы

Безопасность браузера предотвращает программное присвоение значений file inputs. Для сброса либо вызовите inputRef.current.value = '' используя ref (что работает для очистки, но не для установки), либо размонтируйте и перемонтируйте input, изменив его prop key. Оба подхода эффективно очищают выбор, соблюдая ограничения браузера.

Создайте массив состояний загрузки, по одному на файл. Каждый объект состояния отслеживает свой собственный прогресс, статус и любые ошибки. Используйте имя файла или сгенерированный ID в качестве ключа. При загрузке пройдитесь по файлам и создайте отдельные экземпляры XMLHttpRequest, обновляя соответствующую запись состояния в каждом callback прогресса.

Событие dragLeave срабатывает при перемещении между родительским и дочерними элементами. Исправьте это, проверяя, содержится ли связанная цель внутри зоны drop перед установкой isDragging в false, или используйте счётчик, который увеличивается на dragEnter и уменьшается на dragLeave, обновляя состояние только когда счётчик достигает нуля.

Используйте FormData в большинстве случаев. Он эффективно обрабатывает бинарные данные и поддерживает кодировку multipart/form-data, которую ожидают серверы. Base64-кодирование увеличивает размер файла примерно на 33% и требует больше обработки с обеих сторон. Используйте base64 только когда ваш API специально этого требует или при встраивании небольших файлов в JSON-payload.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay