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:

FieldMeaning
idStable id for the row; also the argument to abort and removeFile.
fileThe File as selected.
status'idle' | 'uploading' | 'success' | 'error'.
progress0–100 while uploading, 100 on success.
errorHuman message, from validation or the strategy's failure.
previewObject URL for images — a local thumbnail before uploading.
storedThe 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 styled Uploader defaults to this.
  • uploadOn: 'manual' (hook default) — files wait in idle until the app calls upload(). 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:

ModeWho sendsUse it for
'select'The zone, the moment a file landsAvatars, quick replacements — the file IS the action
'submit'The form, via controllerRefAny file that depends on the rest of a form to make sense (documents, catalogs)
'manual'The zone's own upload buttonEvidence 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 through setValue, and a callback that already received its data argument would read the values from before it.
  • onPendingChange is what tells the form it has unsent work. A staged file never touches the fields, so a save button gated on isDirty alone 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,
  • retry re-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.
  • concurrency caps 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.

On this page