gw-react-file-input
v0.1.0
Published
Typed React file input components with drag and drop, previews, and Result-based uploads.
Maintainers
Readme
gw-react-file-input
Typed React 19 file inputs with drag and drop, initial values, validation,
progress, cancellation, retry, previews, and explicit upload results powered by
gw-result.
Install
npm install gw-react-file-inputreact@^19 is required as a peer dependency.
Quick start
import {
FileInputArea,
useFileInputController,
useFileSnapshots,
useIsDragOver,
} from "gw-react-file-input";
type UploadedFile = {
id: string;
url: string;
};
export default function UploadArea() {
const controller = useFileInputController<UploadedFile>({
accept: "image/*,.pdf",
maxFileSize: 10 * 1024 * 1024,
maxFiles: 5,
concurrency: 2,
uploader: async (file, { signal, onProgress }) => {
// Pass signal to fetch/XHR and call onProgress with a value from 0 to 100.
onProgress(10);
const body = new FormData();
body.append("file", file);
const response = await fetch("/api/files", {
method: "POST",
body,
signal,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
onProgress(100);
return response.json() as Promise<UploadedFile>;
},
});
const snapshots = useFileSnapshots(controller);
const isDragOver = useIsDragOver(controller);
return (
<>
<FileInputArea controller={controller} className="drop-area">
{isDragOver ? "Drop files here" : "Choose or drag files"}
</FileInputArea>
<ul>
{snapshots.map((snapshot) => (
<li key={snapshot.uniqueKey}>
{snapshot.name}
{snapshot.status === "preparing" ||
snapshot.status === "uploading" ? (
<progress value={snapshot.progress} max={100} />
) : snapshot.status === "error" ? (
<>
<span role="alert">Upload failed</span>
<button onClick={() => controller.retry(snapshot)}>Retry</button>
</>
) : (
<a href={snapshot.file.url}>Open</a>
)}
<button onClick={() => controller.remove(snapshot)}>Remove</button>
</li>
))}
</ul>
</>
);
}Initial files with defaultValue
Initial files start in the uploaded state with progress: 100. They are
included in uploadedFiles and count toward maxFiles from the first render.
const controller = useFileInputController<UploadedFile>({
maxFiles: 3,
defaultValue: [
{
file: { id: "existing-1", url: "/files/existing-1" },
name: "existing.png",
type: "image/png",
size: 42_000,
width: 1200,
height: 800,
thumbnail: "/files/existing-1/thumbnail",
},
],
});defaultValueis read when the controller is created. Changing the prop later does not reset the user's current selection.- With
multiple: false, only the first default value is retained. - Default values do not have a local
sourcefile, but can be removed normally. - Removing a default value frees a
maxFilesslot.
Snapshot states
FileSnapshot<TFile> is a discriminated union. Check status before reading
state-specific properties.
| Status | isLoading | Available data |
| --- | --- | --- |
| preparing | true | Source file, metadata, progress |
| uploading | true | Source file, metadata, progress |
| uploaded | false | Uploaded file: TFile, progress 100 |
| error | false | error, source file, retry support |
This prevents loading or failed snapshots from pretending to contain an uploaded file.
Result-based uploads
controller.upload(files) returns a gw-result Result:
const result = await controller.upload(files);
if (result.isOk) {
console.log(result.value); // TFile[]
} else {
console.log(result.error.uploadedFiles); // Partial successes
console.log(result.error.failures); // Uploader errors or cancellations
console.log(result.error.rejections); // Type, size, or count rejection
}Failures contain reason: "upload" | "aborted". Rejections contain
code: "file-type" | "file-size" | "file-count".
Callbacks are also available:
useFileInputController({
onUploaded: (files) => {
// Successful files from this upload batch only.
console.log("uploaded in this batch", files);
},
onUploadError: ({ file, error, reason }) =>
console.error(file.name, reason, error),
onRejected: (rejections) => console.warn(rejections),
});onUploaded is called once per completed upload() batch and receives only the
successful files from that batch. It does not include defaultValue, files from
earlier batches, or other files already held by the controller. When a batch
partially fails, it still receives that batch's successful files. A successful
retry() calls it with the retried file.
Use controller.uploadedFiles when you need the cumulative list of every
currently uploaded file, including initial defaultValue entries and successful
files from previous batches:
console.log(controller.uploadedFiles); // Current cumulative TFile[]Validation
Validation applies equally to picker selections, drag-and-drop, and imperative
controller.upload() calls.
useFileInputController({
accept: "image/*,.pdf",
maxFileSize: 5 * 1024 * 1024,
maxFiles: 4,
});The default maximum file size is 25 MiB. accept is client-side validation and
does not replace server-side MIME and content validation.
Concurrency, progress, and cancellation
Uploads run concurrently by default. Limit active uploader calls with
concurrency:
const controller = useFileInputController({
concurrency: 3,
uploader: async (file, { signal, onProgress }) => {
return uploadWithProgress(file, { signal, onProgress });
},
});concurrency: 1performs sequential uploads.remove(snapshot)andclear()abort metadata work and the uploader signal.- Starting a new upload in single-file mode aborts the previous pending file.
- Uploaders should honor
signalto stop their underlying network operation. State and callbacks are protected even when an uploader ignores it.
Retry
Failed snapshots remain visible and can be retried with their original source file:
if (snapshot.status === "error") {
await controller.retry(snapshot);
}Components
FileInputButton
Renders a native button and a hidden file input.
<FileInputButton accept="image/*" disabled={false}>
Upload image
</FileInputButton>Button props such as className, style, ARIA attributes, and click handlers
apply to the visible button. Low-level input attributes belong in inputProps.
FileInputArea
Renders a keyboard-accessible drop area using a div with role="button".
Enter and Space open the picker. Drag callbacks supplied by the consumer are
composed with the internal handlers. children is a regular ReactNode; use
useIsDragOver(controller) when the content depends on drag state.
SingleFileInput
Always uses single-file mode. A caller cannot override it with
multiple={true}.
<SingleFileInput
overlay={<span>Drop or add a file</span>}
>
{(snapshot) => <FilePreview snapshot={snapshot} />}
</SingleFileInput>FilePreview
FilePreview renders one snapshot's pending, error, generated thumbnail,
fallback, retry, and remove UI. It does not assume any shape for the uploaded
TFile value. Image and video thumbnails are bounded to 512 pixels, media work
times out safely, and temporary object URLs are revoked.
<FilePreview
snapshot={snapshot}
pending={<span>Uploading...</span>}
error={<span>Upload failed</span>}
onRetry={(failed) => controller.retry(failed)}
onRemove={(current) => controller.remove(current)}
slotProps={{
pending: { className: "preview-loading" },
error: { className: "preview-error" },
thumbnail: { className: "preview-image" },
retryButton: { className: "preview-retry-button" },
removeButton: { className: "preview-remove-button" },
}}
/>For the common case, FilePreviewList subscribes to the controller, renders all
snapshots, and wires retry and remove actions automatically:
<FilePreviewList
controller={controller}
empty={<span>No files</span>}
previewProps={{
className: "preview-item",
slotProps: {
thumbnail: { className: "preview-image" },
removeButton: { className: "preview-remove-button" },
},
}}
/>Every internal element accepts standard DOM props through slotProps and has a
stable data-slot attribute: root, pending, error, thumbnail,
progress, fallback, actions, retry-button, and remove-button.
The data URL uploader is convenient for previews but increases file memory usage. Use a custom uploader for production files and keep an explicit size limit.
Controller ownership
The recommended API creates one stable controller and passes it directly to hooks and components:
const controller = useFileInputController(options);
const snapshots = useFileSnapshots(controller);
const isDragOver = useIsDragOver(controller);
<FileInputArea controller={controller}>Upload</FileInputArea>;Ref-based access remains supported:
const ref = useRef<FileInputController<UploadedFile> | null>(null);
const snapshots = useFileSnapshots(ref);
<FileInputArea ref={ref} uploader={uploadFile}>Upload</FileInputArea>;When a controller prop is provided, defined component options override the
matching controller options. Unspecified options remain unchanged.
Native form behavior
The hidden file input is cleared after each selection so the same file can be
selected again. For that reason, native name, required, value, and form
submission behavior are intentionally not exposed. Submit uploaded IDs or URLs
from application state instead.
Migrating from dn-react-file-input
Version 0.1.0 is published under the new package name:
- import { FileInputArea } from "dn-react-file-input";
+ import { FileInputArea } from "gw-react-file-input";Important changes:
useFiles()was renamed touseFileSnapshots()because it returns snapshot state rather than uploaded files.base64Uploaderwas renamed todataUrlUploaderto describe its actual return format.FileInputSocketwas renamed toSingleFileInput.- Prefer
useFileInputController()and pass the controller directly. - Narrow snapshots with
snapshot.statusbefore readingsnapshot.file. upload()now returns agw-resultResult.- Failed snapshots remain available for retry instead of disappearing.
- Use
concurrency={1}for sequential uploads. FileInputArearenders an accessible drop-areadiv, not a native button.- Native form-only props such as
nameandrequiredare no longer exposed.
Development
npm install
npm run check
npm --prefix example install
npm --prefix example run build