Загрузка файлов

Загрузка файлов в TanStack Query строится вокруг мутаций, поскольку операция относится к изменению состояния сервера и не является чистым чтением данных. Основная цель — связать процесс отправки файлов с управляемым жизненным циклом запроса, сохранив контроль над состоянием загрузки, ошибками, повторными попытками и синхронизацией кеша.

Загрузка файлов почти всегда опирается на FormData, так как именно этот формат поддерживает multipart-запросы, необходимые для передачи бинарных данных.

import { useMutation } from '@tanstack/react-query'

async function uploadFile(file) {
  const formData = new FormData()
  formData.append('file', file)

  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData,
  })

  if (!response.ok) {
    throw new Error('Ошибка загрузки файла')
  }

  return response.json()
}

export function useFileUpload() {
  return useMutation({
    mutationFn: uploadFile,
  })
}

Мутация в таком виде уже предоставляет базовую инфраструктуру: состояние загрузки, обработку ошибок и возможность интеграции с кешем запросов.

Использование состояния мутации

TanStack Query автоматически управляет состояниями:

  • isPending — загрузка выполняется
  • isSuccess — загрузка завершена успешно
  • isError — произошла ошибка
  • data — результат сервера
const upload = useFileUpload()

upload.mutate(file, {
  onSuccess: (data) => {
    console.log('Файл загружен', data)
  },
  onError: (error) => {
    console.log('Ошибка', error)
  }
})

Состояние мутации используется для построения интерфейсов прогресса, блокировки кнопок и отображения статусов.

Интеграция с axios для прогресса загрузки

Fetch API не предоставляет удобного прогресса загрузки, поэтому часто используется axios.

import axios from 'axios'

async function uploadFile(file, onProgress) {
  const formData = new FormData()
  formData.append('file', file)

  const response = await axios.post('/api/upload', formData, {
    onUploadProgress: (event) => {
      const percent = Math.round((event.loaded * 100) / event.total)
      onProgress(percent)
    },
  })

  return response.data
}

Интеграция с TanStack Query:

export function useFileUpload() {
  return useMutation({
    mutationFn: ({ file, onProgress }) => uploadFile(file, onProgress),
  })
}

Прогресс загрузки и UI-состояние

Прогресс часто хранится вне кеша TanStack Query, так как он является временным и частым состоянием.

const upload = useFileUpload()
const [progress, setProgress] = useState(0)

const handleUpload = (file) => {
  upload.mutate({
    file,
    onProgress: setProgress,
  })
}

UI может реагировать на процент загрузки, комбинируя его с состоянием мутации:

  • progress < 100 — активная загрузка
  • isSuccess — завершено
  • isError — ошибка

Оптимистическое добавление файла в список

Часто требуется отображать файл в списке сразу после выбора, до завершения загрузки.

const queryClient = useQueryClient()

const upload = useMutation({
  mutationFn: uploadFile,

  onMutate: async (file) => {
    await queryClient.cancelQueries({ queryKey: ['files'] })

    const previous = queryClient.getQueryData(['files'])

    const optimisticFile = {
      id: Date.now(),
      name: file.name,
      status: 'uploading',
    }

    queryClient.setQueryData(['files'], (old = []) => [
      ...old,
      optimisticFile,
    ])

    return { previous, optimisticFile }
  },

  onError: (_err, _file, context) => {
    queryClient.setQueryData(['files'], context.previous)
  },

  onSuccess: (data, _file, context) => {
    queryClient.setQueryData(['files'], (old = []) =>
      old.map((f) =>
        f.id === context.optimisticFile.id ? data : f
      )
    )
  },
})

Такой подход создаёт ощущение мгновенной реакции интерфейса.

Инвалидация кеша после загрузки

Если список файлов полностью управляется сервером, проще выполнить инвалидацию:

onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['files'] })
}

Инвалидация гарантирует консистентность данных, особенно если сервер добавляет метаданные (размер, URL, тип).

Обработка ошибок загрузки

Ошибки загрузки могут возникать на разных этапах: сеть, сервер, валидация файла.

const upload = useMutation({
  mutationFn: uploadFile,
  retry: 2,
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 8000),
})

Повторные попытки особенно полезны при нестабильной сети.

При необходимости можно отключить повтор:

retry: false

Отмена загрузки файлов

Для больших файлов важно уметь прерывать загрузку.

async function uploadFile(file, signal) {
  const formData = new FormData()
  formData.append('file', file)

  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData,
    signal,
  })

  if (!response.ok) {
    throw new Error('Ошибка загрузки')
  }

  return response.json()
}

Использование AbortController:

const controller = new AbortController()

upload.mutate({
  file,
  signal: controller.signal,
})

// отмена
controller.abort()

Параллельные загрузки файлов

При загрузке нескольких файлов каждая мутация должна быть независимой.

files.forEach((file) => {
  upload.mutate(file)
})

Для отслеживания общего состояния часто используется внешний массив:

  • количество активных загрузок
  • агрегированный прогресс
  • очередь загрузки

Очереди загрузки и контроль параллелизма

Для ограничения нагрузки используется очередь:

const queue = []
let active = 0
const MAX = 3

function next() {
  if (active >= MAX || queue.length === 0) return

  const file = queue.shift()
  active++

  upload.mutate(file, {
    onSettled: () => {
      active--
      next()
    },
  })

  next()
}

Такой подход полезен при больших пакетных загрузках.

Чанкинг больших файлов

Разбиение файла на части повышает устойчивость и позволяет возобновлять загрузку.

function createChunks(file, size = 2 * 1024 * 1024) {
  const chunks = []
  let start = 0

  while (start < file.size) {
    chunks.push(file.slice(start, start + size))
    start += size
  }

  return chunks
}

Каждый чанк отправляется отдельной мутацией:

async function uploadChunk(chunk, index) {
  const formData = new FormData()
  formData.append('chunk', chunk)
  formData.append('index', index)

  return fetch('/api/upload-chunk', {
    method: 'POST',
    body: formData,
  })
}

Сборка файла на сервере выполняется отдельно после завершения всех частей.

Синхронизация состояния загрузки с кешем

TanStack Query позволяет хранить статус загрузки в кеше:

queryClient.setQueryData(['upload-status', fileId], {
  progress,
  status: 'uploading',
})

Это полезно, если прогресс должен отображаться в разных компонентах интерфейса.

Сложные сценарии восстановления загрузки

При разрыве соединения важно сохранять состояние:

  • уже загруженные чанки
  • процент выполнения
  • идентификатор сессии загрузки
const state = queryClient.getQueryData(['upload-session', fileId])

При восстановлении можно продолжить с последнего успешного чанка.

Комбинация с файловыми списками

После загрузки файл обычно добавляется в список сущностей:

onSuccess: (data) => {
  queryClient.setQueryData(['files'], (old = []) => [
    data,
    ...old,
  ])
}

Это уменьшает необходимость повторной загрузки всего списка.

Практическая структура слоя загрузки

Типовая архитектура включает:

  • API слой (uploadFile, uploadChunk)
  • кастомные хуки TanStack Query
  • UI слой с прогрессом
  • кеш файлов
  • очередь загрузок

Разделение позволяет масштабировать систему до сложных сценариев: массовые загрузки, фоновые задачи, восстановление сессий и распределённая загрузка больших файлов.