useUpload
Headless React hook that uploads a queue of files to a named route, with progress, cancel, retries, and multipart.
useUpload takes files from a picker, a drop, a paste, or your own code. It
checks them against the route's policy, then presigns, uploads, and confirms
each one. The server builds the object key; the hook only sends the route
name.
"use client";
import { useUpload } from "@dimah-s3/react";
export function FileUploader() {
const upload = useUpload({
route: "avatar",
onSuccess: (result) => {
console.log("Uploaded:", result.key);
},
});
return (
<div
{...upload.getRootProps({
className: "cursor-pointer rounded-lg border border-dashed p-6",
})}
>
<input {...upload.getInputProps()} />
{upload.isPending
? `Uploading: ${upload.progress.percent}%`
: "Click or drop a file here"}
</div>
);
}For ready-made controls, pass the return value to the upload components.
Taking files in
getRootProps(props?, call?)— spread on a dropzone. It handles drag and drop, paste, click, and Enter or Space to open the picker.getInputProps(props?)— spread on a hidden<input type="file">.open(call?)— opens the file picker from your own button.handleFiles(files, call?)— uploads aFile, aFile[], or aFileListfrom your own code, such as a camera capture.getDropTargetProps(props?, call?)— drag and drop handlers only. It adds notabIndex, no click or keyboard handler, and norole, so the element keeps its own interactions. Use it for drop targets such as folder rows.
const upload = useUpload({ route: "avatar", noClick: true });
return (
<div {...upload.getRootProps()}>
<input {...upload.getInputProps()} />
<button type="button" onClick={() => upload.open()}>
Choose file
</button>
</div>
);isDragActive, isDragAccept, and isDragReject describe the files dragged
over a dropzone or drop target. disabled, noDrag, noClick, and
noKeyboard turn intake off. With disabled, open() does nothing too, and
the return's disabled field is true so your pickers can render disabled.
handleFiles still uploads the files you pass it.
The queue
Every file of a call joins one queue. maxFiles caps the files per call
(default 1), and concurrentFiles uploads that many at once (default 2).
const upload = useUpload({
route: "attachments",
maxFiles: 10,
concurrentFiles: 3,
queue: true,
});By default a new call cancels the files still in flight and replaces the
queue once its own files start uploading. Earlier calls that are still
preparing are cancelled at once. A call that is blocked, rejected, or fails
before uploading leaves the queue alone. With queue: true, new files are
added to the end and the files in flight keep going.
Each entry in upload.files has:
id— pass it tocancel(id).phase—queued,preparing,presigning,uploading,finalizing,success, orerror.progress—{ loaded, total, percent, speed? }.error— anAPIError, set whenphaseiserror.result— the verified object, set whenphaseissuccess.fileName,fileSize,contentType,previewUrl— for display.previewUrlis an object URL for images; the hook revokes it.
<ul>
{upload.files.map((file) => (
<li key={file.id}>
{file.fileName} — {file.phase} ({file.progress.percent}%)
{file.phase !== "success" && file.phase !== "error" ? (
<button type="button" onClick={() => upload.cancel(file.id)}>
Cancel
</button>
) : null}
</li>
))}
</ul>The hook also sums up the queue:
phase—idle,uploading,success, orerror.progress— bytes across every file in the queue.error— the failed file's error, orPARTIAL_FAILUREwhen several failed.isPending—truewhile a call is preparing or a file is in flight.file— the first file, ornull. Handy whenmaxFilesis1.
cancel() with no id cancels every file. reset() removes settled files and
rejections; files in flight keep going.
retry(id) uploads a failed file again, and retry() every failed file. A
retried file keeps its place and id, and goes back to queued. It goes with
the call options it was first sent with, so it lands in the same folder: it
is validated again, and beforeUpload and onUploadStart run again. A retry
never replaces the queue, even without queue: true. It resolves like
handleFiles, and the callbacks run as for a new call.
{
upload.files.map((file) =>
file.phase === "error" ? (
<button key={file.id} type="button" onClick={() => upload.retry(file.id)}>
Retry {file.fileName}
</button>
) : null,
);
}Results
handleFiles does not throw for upload errors. It resolves once every file of
the call has ended:
const { uploaded, failed, blocked, aborted } = await upload.handleFiles(files);
for (const { file, result } of uploaded) console.log(file.name, result.key);
for (const { file, error } of failed) console.log(file.name, error.code);blocked holds the files beforeUpload stopped, and aborted the files that
were cancelled or detached. Files rejected before upload are in
upload.fileRejections, not in the result.
result is the object the server verified with HeadObject: key,
bucket, size, name, type, metadata, and the other fields of
UploadResult.
Callbacks
Callbacks run per call or per file:
| Callback | Runs |
|---|---|
onFileReject(rejections) | Once per call with rejected files, before anything else. |
beforeUpload(files) | Once per call. Return false to block it. A throw fails it (HOOK_FAILED). |
onUploadStart(files) | Once per call, after beforeUpload allows it. |
onProgress(progress, file) | As bytes are sent. |
onMultipartInit(session, file) | When a multipart upload starts or resumes. |
onPartUpload(part, file) | After each multipart part. |
onError(error, file) | Once per failed file, after every file of the call has ended. |
onSuccess(result, file) | Once per uploaded file, after every onError of the call. |
onSettled(result) | Once per call, last. It also runs when every file was rejected. |
onSuccess, onSettled, and the other after-hooks are awaited. If one
throws, the error goes to the global reportError and the result does not
change.
Request options
uploadOptions sets the request for each file: metadata for the server
object callback, and fileName or contentType to override what the
browser reports. Pass an object, or a function that gets the file.
const upload = useUpload({
route: "attachments",
uploadOptions: (file) => ({
metadata: { originalName: file.name },
}),
});A single call can add its own options. Call fields win, and metadata
merges key by key:
await upload.handleFiles(files, {
uploadOptions: { metadata: { folder: "invoices" } },
maxFiles: 3, // lowers maxFiles for this call
});open, getRootProps, and getDropTargetProps take the same call options.
The two prop getters also take a function, so the options are read when the
files arrive:
<li
{...upload.getDropTargetProps({ className: "folder-row" }, () => ({
uploadOptions: { metadata: { folder: folder.id } },
}))}
>
{folder.name}
</li>A drop on a target inside a dropzone uses the target's call options.
metadata reaches the server as clientMetadata. It is written to S3 only
when the route's object callback copies it.
Route policy
Each call loads the route catalog (GET /routes) before it presigns. The
catalog is cached per S3Api, so every hook shares one request.
upload.policy is the route's policy narrowed by the hook options:
acceptandmaxFileSize— the stricter of the route and the hook wins.multipart— on when the route allows it and the hook does not passmultipart: false.checksum— follows the route. When it is on, single-shot uploads send a SHA-256 of the file.maxFiles— the hook'smaxFiles.statusanderror— where the catalog is in loading.
Files that break the policy are rejected without stopping the others. Each
rejection in upload.fileRejections carries APIErrors with catalog codes:
FILE_TYPE_NOT_ALLOWED, FILE_EMPTY, FILE_TOO_LARGE, or TOO_MANY_FILES.
Show them with useFormatDimahError.
import { useFormatDimahError, type UseUploadReturn } from "@dimah-s3/react";
export function Rejections({ upload }: { upload: UseUploadReturn }) {
const formatError = useFormatDimahError();
return upload.fileRejections.map(({ file, errors }) => (
<p key={file.name} role="alert">
{file.name}: {errors.map(formatError).join(", ")}
</p>
));
}To read a route's policy without uploading, for example to label a custom
picker, use useRouteUploadPolicy:
import { useRouteUploadPolicy } from "@dimah-s3/react";
const policy = useRouteUploadPolicy({ route: "avatar" });
// policy.accept, policy.maxFileSize, policy.statusMultipart, retries, and unmount
Files of at least 50 MiB (DEFAULT_MULTIPART_THRESHOLD) use multipart when
the policy allows it. The server picks the part size and count. Parts upload
concurrentParts at a time (default 4), and each part is hashed as it
streams when the server asks for checksums.
Failed requests retry with exponential backoff. Set retry: { maxRetries, baseDelay } (defaults 3 and 1000 ms). Network errors, S3 errors with
status 429 or 500 and above, and expired part URLs are retried. An expired URL
is signed again first. A file that still fails can be sent again with
upload.retry(id); with an uploadStore, a multipart upload that failed on a
retryable error resumes from the parts S3 already has.
cancel(id?)aborts the upload, aborts the multipart session on the server, and deletes its stored record.detach()stops every file but keeps the multipart session and its stored record, so the upload can resume.- Unmounting detaches files whose call had an
uploadStoreand cancels the rest.
Type reference
import type {
UploadBatchResult,
UploadCallOptions,
UploadFileState,
UploadPolicy,
UploadRequestOptions,
UseUploadOptions,
UseUploadReturn,
} from "@dimah-s3/react";UseUploadOptions
Prop
Type
UseUploadReturn
Prop
Type
UploadFileState
Prop
Type
UploadBatchResult
Prop
Type
UploadCallOptions
Prop
Type
UploadRequestOptions
Prop
Type
UploadPolicy
Prop
Type
UseRouteUploadPolicyOptions
Prop
Type
Frequently asked questions
Every file of that call fails with the catalog's error, and policy.status is
"error". The next call loads the catalog again. A custom backend must
implement catalog(); see Bring Your Own Backend.
Read previewUrl from an entry in upload.files. upload.file?.previewUrl
is the single-file shorthand:
export function AvatarWithPreview() {
const upload = useUpload({ route: "avatar" });
const preview = upload.file?.previewUrl;
return (
<div {...upload.getRootProps()}>
<input {...upload.getInputProps()} />
{preview ? (
<img
src={preview}
alt="Selected avatar"
className="size-20 rounded-full object-cover"
/>
) : (
"Select avatar"
)}
</div>
);
}Without queue: true, a new call replaces the queue and cancels the files in
flight. Set queue: true to append instead.
Call detach() to stop without aborting, then select the same file again to
resume. Resuming needs an uploadStore and a multipart upload; see
Upload Store.