2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0

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.

components/file-uploader.tsx
"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 a File, a File[], or a FileList from your own code, such as a camera capture.
  • getDropTargetProps(props?, call?) — drag and drop handlers only. It adds no tabIndex, no click or keyboard handler, and no role, 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 to cancel(id).
  • phase — queued, preparing, presigning, uploading, finalizing, success, or error.
  • progress — { loaded, total, percent, speed? }.
  • error — an APIError, set when phase is error.
  • result — the verified object, set when phase is success.
  • fileName, fileSize, contentType, previewUrl — for display. previewUrl is 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, or error.
  • progress — bytes across every file in the queue.
  • error — the failed file's error, or PARTIAL_FAILURE when several failed.
  • isPending — true while a call is preparing or a file is in flight.
  • file — the first file, or null. Handy when maxFiles is 1.

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:

CallbackRuns
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:

  • accept and maxFileSize — the stricter of the route and the hook wins.
  • multipart — on when the route allows it and the hook does not pass multipart: false.
  • checksum — follows the route. When it is on, single-shot uploads send a SHA-256 of the file.
  • maxFiles — the hook's maxFiles.
  • status and error — 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.status

Multipart, 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 uploadStore and 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

On this page