React uploads

@beignet/react-uploads adds React hook state on top of the typed browser upload client from @beignet/core/uploads/client. It does not define another upload protocol. The core client still owns prepare, direct upload, server fallback, completion, typed route names, metadata, and errors.

Use it when a component needs upload progress, pending state, errors, reset, or abort behavior.

Install

bun add @beignet/react-uploads

Create the adapter

Create the upload client once, then wrap it for React:

// client/uploads.ts
import { createUploadClient } from "@beignet/core/uploads/client";
import { createReactUploads } from "@beignet/react-uploads";
import type { postUploads } from "@/features/posts/uploads";

type AppUploads = typeof postUploads;

export const uploads = createUploadClient<AppUploads>({
  baseUrl: "/api/uploads",
});

export const reactUploads = createReactUploads({
  uploads,
});

Same-origin requests carry the session cookie automatically, so the client needs no identity headers. Apps that scope uploads by tenant can pass a headers function (for example "x-tenant-id").

Import the upload registry as a type so browser code does not bundle server-only upload hooks.

Use an upload hook

Bind the hook to an upload name:

"use client";

import { reactUploads } from "@/client/uploads";

export function AttachmentInput({ postSlug }: { postSlug: string }) {
  const attachment = reactUploads.useUpload("posts.attachment");

  return (
    <div>
      <input
        type="file"
        accept={attachment.accept}
        multiple
        disabled={attachment.isUploading}
        onChange={(event) => {
          const files = Array.from(event.currentTarget.files ?? []);
          if (files.length === 0) return;

          attachment.upload({
            metadata: { postSlug },
            files,
          });
        }}
      />

      {attachment.isUploading && <p>{attachment.progress}%</p>}
      {attachment.isError && <p>Upload failed</p>}
    </div>
  );
}

The upload(...) call is fire-and-forget: it never rejects, so event handlers like the onChange above do not need await or .catch(...). Failures land in hook state and the onError callback.

The hook returns:

PropertyPurpose
status"idle", "preparing", "uploading", "completing", "success", or "error"
progressAggregate progress from 0 to 100
progressFractionAggregate progress from 0 to 1
filesPer-file progress state
resultSuccessful upload completion result
errorLatest upload error
acceptFile input accept value from the upload manifest
constraintsClient-safe file constraints from the upload manifest
upload(...)Start an upload with an explicit files array; never rejects
uploadAsync(...)Start an upload and return the result; rejects on failure
uploadFile(...)Start an upload with one file; never rejects
uploadFileAsync(...)Start an upload with one file and return the result; rejects on failure
abort()Abort the active upload request
reset()Clear local hook state

Awaiting an upload

Use uploadAsync(...) or uploadFileAsync(...) when the caller needs the completion result or wants to sequence work after the upload. The promise rejects when the upload fails or is aborted, so handle the rejection:

try {
  const completed = await attachment.uploadAsync({
    metadata: { postSlug },
    files,
  });
  console.log(completed.result);
} catch {
  // The failure is also stored in hook state and passed to onError.
}

Many-file ergonomics

Use useUploadMany(...) when the component already has a file array and you want a file-first call signature:

const attachments = reactUploads.useUploadMany("posts.attachment");

attachments.upload(files, {
  metadata: { postSlug },
});

useUploadMany(...) exposes the same split: upload(files, options) never rejects, and uploadAsync(files, options) returns the completion result and rejects on failure.

Success and error callbacks

Callbacks can be defined on the hook or per upload call. Use onSuccess to invalidate React Query state or close a dialog after the upload creates app records.

const attachment = reactUploads.useUpload("posts.attachment", {
  onSuccess() {
    rq(getPost).invalidate(queryClient, { path: { slug: postSlug } });
  },
});

The adapter intentionally does not hide TanStack Query. Uploads are imperative side effects; cache invalidation should stay app-owned and explicit.

Callbacks run after the upload settles and never change upload state. When a callback throws, uploadAsync(...) rejects with the callback error and upload(...) reports it through console.error; a succeeded upload stays status: "success" either way.

Progress reporting

Progress depends on the transport the core upload client selects:

Treat progress bars as an enhancement for direct uploads and prefer indeterminate pending UI when forcing strategy: "server".