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

In a generated full-stack app, scaffold the upload definition and a connected React component together:

bun beignet make upload todos.attachment --ui

The command creates a client-safe constraint manifest shared with the server definition, a typed upload client, an uploader with progress and cancellation, and a component test. Use make upload todos.attachment without --ui for a backend-only workflow.

Create the adapter

The generator creates a feature-scoped client in features/todos/client/attachment-upload.ts. Reuse that helper in the generated component. For a central client shared by additional components, create this file:

// client/uploads.ts
import { createUploadClient } from "@beignet/core/uploads/client";
import { createReactUploads } from "@beignet/react-uploads";
import type { todoUploads } from "@/features/todos/uploads";
import { attachmentUploadManifest } from "@/features/todos/attachment-upload-manifest";

type AppUploads = typeof todoUploads;

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

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

Same-origin requests carry the session cookie automatically, so the client needs no identity headers. Apps that need per-request client metadata can pass a headers function, but tenant and identity decisions should still be made from verified server-side state.

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. Pass a Todo ID owned by the signed-in user; Uploads adds the ownership check to the definition:

// features/todos/components/attachment-input.tsx
"use client";

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

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

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

          attachment.upload({
            metadata: { resourceId },
            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()Abort active work and 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: { resourceId },
    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("todos.attachment");

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

useUploadMany(...) exposes the same split: upload(files, options) never rejects, and uploadAsync(files, options) returns the completion result and rejects on failure. It does not bypass file-count constraints: this attachment manifest still accepts one file per upload.

Success and error callbacks

Callbacks can be defined on the hook or per upload call. Use onSuccess to read the completion result or update the surrounding UI:

const attachment = reactUploads.useUpload("todos.attachment", {
  onSuccess({ result }) {
    console.log("Stored attachment keys", result.objectKeys);
  },
});

If you extend the upload definition to create application records, invalidate the affected React Query queries here. The default definition stores objects and returns their keys; it does not change Todo records or cached queries.

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".