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:
| Campo | Significado |
|---|---|
id | Id estable para la fila; también el argumento de abort y removeFile. |
file | El File tal como se seleccionó. |
status | 'idle' | 'uploading' | 'success' | 'error'. |
progress | 0–100 mientras sube, 100 al terminar. |
error | Mensaje humano, de la validación o de la falla de la estrategia. |
preview | Object URL para imágenes — miniatura local antes de subir. |
stored | El 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. ElUploadercon estilos usa este default.uploadOn: 'manual'(default del hook) — los archivos esperan enidlehasta que la app llamaupload(). 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:
| Modo | Quién manda | Úsalo para |
|---|---|---|
'select' | La zona, en cuanto aterriza un archivo | Avatares, reemplazos rápidos — el archivo ES la acción |
'submit' | El formulario, vía controllerRef | Todo archivo que depende del resto de un form para tener sentido (documentos, catálogos) |
'manual' | El botón propio de la zona | Evidencias 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íasetValue, y un callback que ya recibió su argumentodataleería los valores de antes. onPendingChangees 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 aisDirtyse 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,retryre-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.concurrencylimita 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.
Scopes — el contrato
Declara una vez a dónde va un archivo, quién puede leerlo, qué tamaño admite y qué reemplaza. Ambos lados validan contra la misma definición.
Componentes de UI
La capa opcional de componentes: Uploader, SlottedUploader, confirmaciones, vista previa, lectura de archivos almacenados, soltar para reemplazar, o nada de ello.