Cliente

El hook headless useUploader: progreso, cancelación, reintentos con backoff, límite de concurrencia, compresión, renombrado, validación y slots con nombre.

Cliente

useUploader

La máquina de estados headless: selección → validación → (compresión) → subida con progreso y abort. No renderiza nada; tú renderizas files como la pantalla lo necesite.

import { useUploader } from 'uploaderkit/react'

const { files, accept, addFiles, upload, abort, isUploading, hasPending } =
	useUploader({
		scopes,
		scope: 'invoice-evidence',
		entityId: invoiceId,
		strategy,
		multiple: true,
		maxFiles: 3,
		uploadOn: 'manual', // el default — ver "Disparo de la subida"
		onUploadStart: files => setSending(true),
		onUploaded: stored => saveToDb(stored),
		onError: message => toast.error(message),
		retry: { attempts: 3, backoffMs: 500 },
		concurrency: 3,
	})

<input type='file' accept={accept} onChange={e => addFiles(e.target.files!)} />

Cada entrada de files es un UploaderFile:

CampoSignificado
idId estable para la fila; también el argumento de abort y removeFile.
fileEl File tal como se seleccionó.
status'idle' | 'uploading' | 'success' | 'error'.
progress0–100 mientras sube, 100 al terminar.
errorMensaje humano, de la validación o de la falla de la estrategia.
previewObject URL para imágenes — miniatura local antes de subir.
storedEl StoredFile que confirmó el servidor.

upload() manda todos los archivos que siguen en idle y resuelve con los confirmados. abort(id) cancela una subida, abort() las cancela todas — un archivo abortado vuelve a idle, no a error, para que el usuario reintente sin limpiar nada. hasPending es true mientras algún archivo espera en idle — la bandera que lee un botón de submit.

Omite strategy para selección y validación solo locales.

Disparo de la subida — select vs manual

No toda pantalla quiere el mismo momento. El contrato lo hace explícito en vez de fijar un solo comportamiento:

  • uploadOn: 'select' — el archivo viaja en cuanto valida. Las pantallas de arrastrar / adjuntar y listo: evidencias, avatares, galerías. El Uploader con estilos usa este default.
  • uploadOn: 'manual' (default del hook) — los archivos esperan en idle hasta que la app llama upload(). El flujo de formulario: todos los campos más el documento se confirman como una sola acción en el submit.
const uploader = useUploader({ scopes, scope, entityId, strategy }) // manual

const onSubmit = async (event: FormEvent) => {
	event.preventDefault()
	if (!form.valid || !uploader.hasPending) return
	const stored = await uploader.upload() // dispara aquí, con el submit
	await saveRecord({ ...form.values, file: stored[0] })
}

A nivel de los componentes con estilos la elección es un contrato de tres — uploadOn: 'select' | 'submit' | 'manual' — un modo por tipo de pantalla:

ModoQuién mandaÚsalo para
'select'La zona, en cuanto aterriza un archivoAvatares, reemplazos rápidos — el archivo ES la acción
'submit'El formulario, vía controllerRefTodo archivo que depende del resto de un form para tener sentido (documentos, catálogos)
'manual'El botón propio de la zonaEvidencias y flujos puntuales sin form alrededor — suelta ahora, manda cuando quieras

Prefiere 'submit' siempre que el archivo pertenezca a un formulario que el usuario puede abandonar. Un scope de key estable sobrescribe en cada put, así que una subida que dispara al seleccionar ya cambió lo que la entidad sirve — el logo de un cliente, la foto de un producto — aunque el operador le dé Cancelar después. Diferir es lo que hace que "cancelar" signifique cancelar. (SlottedUploader solo ofrece 'select' y 'submit': no tiene superficie de botón, así que un slot en espera bajo 'manual' nunca podría salir.)

El cableado de 'submit':

const uploaderRef = useRef<UploaderController | null>(null)
const [staged, setStaged] = useState(false)

const onSubmit = async () => {
	if (uploaderRef.current?.hasPending) await uploaderRef.current.upload()
	await handleSubmit(save)() // lee los valores que la subida acaba de escribir
}

<Uploader
	{...props}
	uploadOn='submit'
	controllerRef={uploaderRef}
	onPendingChange={setStaged}
/>
<button disabled={!isDirty && !staged}>Guardar</button>

Dos detalles fáciles de equivocar:

  • Vacía la cola antes de handleSubmit(...)(), no dentro del callback de submit. Una subida aterriza en el form vía setValue, y un callback que ya recibió su argumento data leería los valores de antes.
  • onPendingChange es lo que le avisa al form que tiene trabajo sin mandar. Un archivo en espera nunca toca los campos, así que un botón de guardar condicionado solo a isDirty se queda deshabilitado en un formulario limpio al que el usuario acaba de soltarle un archivo.

Bajo 'submit' la zona no renderiza botón de subida propio: dos formas de mandar el mismo batch es una de más, y la del formulario es la que sabe si el resto de los campos son válidos.

onUploadStart(files) se dispara cuando un batch sale de verdad — desde cualquiera de los dos triggers — para que un formulario entre a su estado "enviando" en el momento real, no en la selección.

Reintentos y concurrencia

Ambos opt-in, ambos viviendo por completo dentro del hook:

retry: { attempts: 3, backoffMs: 500 }, // o el atajo: retry: 3
concurrency: 3,
  • retry re-ejecuta una llamada fallida de la estrategia antes de mostrar el error, con backoff exponencial (backoffMs, luego ×2 por intento). Los aborts nunca se reintentan, y las fallas de validación nunca llegan a la estrategia. El progreso de la fila se reinicia entre intentos; el usuario solo ve un error cuando falla el último.
  • concurrency limita cuántos archivos suben a la vez; el resto se encola. Treinta fotos en un móvil ya no son treinta XHR simultáneos.

Renombrar a la entrada

rename reescribe el nombre de cada archivo antes de entrar a la máquina — un folio, un input del cliente, un slug. Corre antes de la validación (un rename que rompe la extensión se rechaza como cualquier archivo inválido), y el path del scope lee el nombre nuevo al armar la key de almacenamiento:

useUploader({
	scopes,
	scope: 'invoice-evidence',
	entityId,
	strategy,
	rename: file => `${folio}-${file.name}`,
})

Los slots con nombre ya renombran a {slot}.{ext} — ese contrato sigue siendo suyo.

Nombres de archivo seguros

sanitizeFileName convierte el nombre del usuario en un segmento de key seguro — ASCII, minúsculas, una sola extensión, sin sintaxis de ruta. Llámalo dentro del path() de tu scope, para que cliente y servidor deriven la misma key:

path: (id, file) => `Docs/${id}/${sanitizeFileName(file.name)}`

La guarda de traversal detrás de resolveKey juzga por segmento de ruta, no por substring: Screenshot … 4.18.54 p.m..png carga .. sin ser traversal jamás, y un screenshot de macOS es el caso común, no uno de esquina. La guarda es el respaldo; el sanitizador es el fix.

Estrategias de subida

Una estrategia es el transporte físico de un archivo. El hook es dueño del estado, la estrategia es dueña de los bytes:

type UploadStrategy = (
	file: File,
	scope: string,
	entityId: string,
	options: { onProgress: (percent: number) => void; signal: AbortSignal }
) => Promise<StoredFile>

La de fábrica hace un POST multipart a POST {endpoint}/{scope}/{entityId}/upload, que es exactamente lo que exponen los adaptadores de framework de más abajo:

import { createXhrUploadStrategy } from 'uploaderkit/react'

const strategy = createXhrUploadStrategy({
	endpoint: `${apiUrl}/storage`,
	// Se evalúa por subida, así un JWT rotativo se lee al momento de mandar.
	headers: () => ({ Authorization: `Bearer ${getToken()}` }),
	fieldName: 'file',
	// Sesiones por cookie: la api responde en otro origen, así que el navegador
	// descarta la cookie de sesión salvo que la petición la pida.
	credentials: 'include',
})

Escribir la tuya es una sola función — PUT firmado directo al bucket, un protocolo resumible, una cola. Abortar debe rechazar con un error llamado AbortError; el hook mapea eso a idle en vez de error.

Validación

Corre en el cliente para dar feedback y otra vez en el servidor por seguridad. Tres chequeos, en orden: extensión contra el accept del scope, tamaño contra maxBytes, y magic numbers — los primeros bytes del archivo, para que un .exe renombrado a .pdf se rechace antes de viajar.

Los mensajes son en español y seguros de mostrar al usuario por diseño; la falla aterriza en files[i].error y en onError.

Compresión de imágenes

Cuando el scope declara compress, las imágenes se reescalan y reencodean en un canvas antes de que la estrategia las vea:

compress: { maxWidth: 512, maxHeight: 512, quality: 0.8, stripExif: true }

El EXIF se pierde como efecto colateral inherente al reencode — las fotos de cámara traen coordenadas GPS, y un bucket público es el peor lugar para eso. El pipeline cae de vuelta al archivo original siempre que no puede ayudar, así que nunca hace fallar una subida. compressImage(file, options) se exporta para usos sueltos.

Slots con nombre (useSlottedUploader)

Para formularios donde cada posición lleva exactamente un documento. Este hook envuelve useUploader tal cual: solo decide en qué slot cae un archivo y lo renombra a {slot}.{ext}, para que el path del scope dé una key estable y resubir sobrescriba en su lugar.

const { slots, accept, addFiles, addToSlot, removeSlot, abort, isUploading } =
	useSlottedUploader({
		scopes,
		scope: 'company-identity',
		entityId: companyId,
		strategy,
		slots: [
			{ id: 'letterhead', label: 'Hoja membretada', extensions: ['pdf'] },
			{ id: 'logo', label: 'Logo', extensions: ['png', 'svg'] },
		],
		value: slotFiles, // SlottedFile[] — la persistencia es tuya
		onChange: setSlotFiles,
		matchBy: 'extension', // o 'name', o un matcher propio en `match`
	})

Las subidas son controladas: value/onChange dejan la persistencia en quien llama, y el hook mezcla las subidas que el padre todavía no absorbe, así dos drops rápidos no pueden hacer que el estado controlado pierda uno.

matchBy: 'extension' (el default) prefiere un slot vacío, así soltar tres archivos llena tres posiciones; 'name' calza un archivo nombrado como su slot (letterhead-a4.pdf → slot letterhead-a4). slotOfStored recupera el slot de un archivo persistido cuando rehidratas desde la base de datos.

On this page