Client
The headless useUploader hook: progress, abort, retry with backoff, concurrency caps, compression, renaming, validation and named slots.
Client
useUploader
The headless state machine: selection → validation → (compression) → upload
with progress and abort. It renders nothing; you render files however the
screen needs.
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', // the default — see "Upload trigger" below
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!)} />Each entry of files is an UploaderFile:
| Field | Meaning |
|---|---|
id | Stable id for the row; also the argument to abort and removeFile. |
file | The File as selected. |
status | 'idle' | 'uploading' | 'success' | 'error'. |
progress | 0–100 while uploading, 100 on success. |
error | Human message, from validation or the strategy's failure. |
preview | Object URL for images — a local thumbnail before uploading. |
stored | The StoredFile the server confirmed. |
upload() sends every file still idle and resolves with the confirmed ones.
abort(id) cancels one upload, abort() cancels all — an aborted file returns
to idle, not error, so the user can retry without clearing anything.
hasPending is true while any file waits in idle — the flag a submit
button reads.
Omit strategy for local-only selection plus validation.
Upload trigger — select vs manual
Not every screen wants the same moment. The contract makes it explicit instead of fixing one behavior:
uploadOn: 'select'— the file travels the moment it validates. The drag-and-drop / attach-and-go screens: evidence, avatars, galleries. The styledUploaderdefaults to this.uploadOn: 'manual'(hook default) — files wait inidleuntil the app callsupload(). The form flow: every field plus the document commit as one action on 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() // fires here, with the submit
await saveRecord({ ...form.values, file: stored[0] })
}onUploadStart(files) fires when a batch actually leaves — from either
trigger — so a form can flip into its "sending" state at the true moment, not
at selection.
At the styled-component level the choice is a three-way contract —
uploadOn: 'select' | 'submit' | 'manual' — one mode per kind of screen:
| Mode | Who sends | Use it for |
|---|---|---|
'select' | The zone, the moment a file lands | Avatars, quick replacements — the file IS the action |
'submit' | The form, via controllerRef | Any file that depends on the rest of a form to make sense (documents, catalogs) |
'manual' | The zone's own upload button | Evidence and punctual flows with no form around them — drop now, send when ready |
Prefer 'submit' whenever the file belongs to a form the user can abandon.
A scope with a stable key overwrites on every put, so an upload that fires on
selection has already changed what the entity serves — a customer's logo, a
product photo — even if the operator then hits Cancel. Deferring is what makes
"cancel" mean cancel. (SlottedUploader offers 'select' and 'submit' only:
it has no button surface, so 'manual' staged slots could never leave.)
The 'submit' wiring:
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)() // reads the values the upload just wrote
}
<Uploader
{...props}
uploadOn='submit'
controllerRef={uploaderRef}
onPendingChange={setStaged}
/>
<button disabled={!isDirty && !staged}>Guardar</button>Two details that are easy to get wrong:
- Flush before
handleSubmit(...)(), not inside the submit callback. An upload settles into the form throughsetValue, and a callback that already received itsdataargument would read the values from before it. onPendingChangeis what tells the form it has unsent work. A staged file never touches the fields, so a save button gated onisDirtyalone stays disabled on a pristine form the user just dropped a file into.
Under 'submit' the zone renders no upload button of its own: two ways to
send the same batch is one too many, and the form's is the one that knows
whether the rest of the fields are valid.
Retry and concurrency
Both opt-in, both living entirely inside the hook:
retry: { attempts: 3, backoffMs: 500 }, // or shorthand: retry: 3
concurrency: 3,retryre-runs a failed strategy call before surfacing the error, with exponential backoff (backoffMs, then ×2 each further attempt). Aborts never retry, and validation failures never reach the strategy at all. The row's progress resets between attempts; the user only sees an error when the last attempt fails.concurrencycaps how many files upload at once; the rest queue. Thirty photos on mobile no longer means thirty simultaneous XHRs.
Renaming on the way in
rename rewrites each file's name before it enters the machine — a folio, a
client-side input, a slug. It runs before validation (a rename that breaks
the extension is rejected like any invalid file), and the scope's path
reads the new name when it builds the storage key:
useUploader({
scopes,
scope: 'invoice-evidence',
entityId,
strategy,
rename: file => `${folio}-${file.name}`,
})Named slots already rename to {slot}.{ext} — that contract stays theirs.
Safe file names
sanitizeFileName turns a user's file name into a safe key segment — ASCII,
lower case, one extension, no path syntax. Call it inside your scope's
path(), so client and server derive the same key:
path: (id, file) => `Docs/${id}/${sanitizeFileName(file.name)}`The traversal guard behind resolveKey judges by path segment, not by
substring: Screenshot … 4.18.54 p.m..png carries .. without ever being
traversal, and a macOS screenshot is the common case, not a corner one. The
guard is the backstop; the sanitizer is the fix.
Upload strategies
A strategy is the physical transport for one file. The hook owns state, the strategy owns bytes:
type UploadStrategy = (
file: File,
scope: string,
entityId: string,
options: { onProgress: (percent: number) => void; signal: AbortSignal }
) => Promise<StoredFile>The default one posts multipart to POST {endpoint}/{scope}/{entityId}/upload,
which is exactly what the framework adapters below expose:
import { createXhrUploadStrategy } from 'uploaderkit/react'
const strategy = createXhrUploadStrategy({
endpoint: `${apiUrl}/storage`,
// Evaluated per upload, so a rotating JWT is read at send time.
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
fieldName: 'file',
// Cookie sessions: the api answers on another origin, so the browser drops
// the session cookie unless the request asks for it.
credentials: 'include',
})Writing your own is one function — direct-to-bucket signed PUT, a resumable
protocol, a queue. Aborting must reject with an error named AbortError; the
hook maps that to idle instead of error.
Validation
Runs on the client for feedback and again on the server for safety. Three
checks, in order: extension against the scope's accept, size against
maxBytes, and magic numbers — the leading bytes of the file, so an .exe
renamed to .pdf is rejected before it travels.
Messages are Spanish and user-safe by design; the failure lands in
files[i].error and in onError.
Image compression
When the scope declares compress, images are downscaled and re-encoded on a
canvas before the strategy sees them:
compress: { maxWidth: 512, maxHeight: 512, quality: 0.8, stripExif: true }EXIF is dropped as an inherent side effect of the re-encode — camera photos
carry GPS coordinates, and a public bucket is the wrong place for them. The
pipeline falls back to the original file whenever it cannot help, so it never
fails an upload. compressImage(file, options) is exported for one-off use.
Named slots (useSlottedUploader)
For forms where each position takes exactly one document. This hook wraps
useUploader verbatim: it only decides which slot a file fills and renames
it to {slot}.{ext}, so the scope's path yields a stable key and a re-upload
overwrites in place.
const { slots, accept, addFiles, addToSlot, removeSlot, abort, isUploading } =
useSlottedUploader({
scopes,
scope: 'company-identity',
entityId: companyId,
strategy,
slots: [
{ id: 'letterhead', label: 'Letterhead', extensions: ['pdf'] },
{ id: 'logo', label: 'Logo', extensions: ['png', 'svg'] },
],
value: slotFiles, // SlottedFile[] — you own persistence
onChange: setSlotFiles,
matchBy: 'extension', // or 'name', or a custom `match` matcher
})Uploads are controlled: value/onChange keep persistence in the caller,
and the hook merges uploads the parent has not absorbed yet, so two quick drops
cannot race the controlled state into losing one.
matchBy: 'extension' (the default) prefers an empty slot, so dropping three
files fills three positions; 'name' matches a file named after its slot
(letterhead-a4.pdf → slot letterhead-a4). slotOfStored recovers the slot
of a persisted file when you rehydrate from the database.
Scopes — the contract
Declare once where a file goes, who may read it, how big it may be and what it replaces. Both sides validate against the same definition.
UI components
The optional component layer: Uploader, SlottedUploader, confirmations, file preview, reading stored files, drop-to-replace, or none of it at all.