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-uploadsCreate 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:
| Property | Purpose |
|---|---|
status | "idle", "preparing", "uploading", "completing", "success", or "error" |
progress | Aggregate progress from 0 to 100 |
progressFraction | Aggregate progress from 0 to 1 |
files | Per-file progress state |
result | Successful upload completion result |
error | Latest upload error |
accept | File input accept value from the upload manifest |
constraints | Client-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:
- Direct uploads report real per-file progress from the browser's
XMLHttpRequestupload events. - Server-strategy uploads stream the whole multipart request through the app
server and only report request completion, so
progressjumps from0to100in one step when the request finishes.
Treat progress bars as an enhancement for direct uploads and prefer
indeterminate pending UI when forcing strategy: "server".