npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

gw-react-file-input

v0.1.0

Published

Typed React file input components with drag and drop, previews, and Result-based uploads.

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-input

react@^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",
    },
  ],
});
  • defaultValue is 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 source file, but can be removed normally.
  • Removing a default value frees a maxFiles slot.

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: 1 performs sequential uploads.
  • remove(snapshot) and clear() abort metadata work and the uploader signal.
  • Starting a new upload in single-file mode aborts the previous pending file.
  • Uploaders should honor signal to 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:

  1. useFiles() was renamed to useFileSnapshots() because it returns snapshot state rather than uploaded files.
  2. base64Uploader was renamed to dataUrlUploader to describe its actual return format.
  3. FileInputSocket was renamed to SingleFileInput.
  4. Prefer useFileInputController() and pass the controller directly.
  5. Narrow snapshots with snapshot.status before reading snapshot.file.
  6. upload() now returns a gw-result Result.
  7. Failed snapshots remain available for retry instead of disappearing.
  8. Use concurrency={1} for sequential uploads.
  9. FileInputArea renders an accessible drop-area div, not a native button.
  10. Native form-only props such as name and required are no longer exposed.

Development

npm install
npm run check
npm --prefix example install
npm --prefix example run build