# useFileUpload

A hook that manages file selection, validation, previews, and upload progress.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/use-file-upload.json
```

[Registry JSON](https://mwui.vercel.app/r/use-file-upload.json)

## Preview

```tsx
import { IconUpload, IconX } from "@tabler/icons-react";

import { Button } from "@/components/ui/button";

import { formatBytes, useFileUpload } from "@/hooks/use-file-upload";

export function Preview() {
  const upload = useFileUpload({
    accept: "image/*,.pdf",
    maxSize: 2 * 1024 * 1024,
    maxFiles: 3,
    autoUpload: true,
    onUpload: (_item, { onProgress, signal }) =>
      new Promise<void>((resolve) => {
        let step = 0;

        const timer = setInterval(() => {
          step += 1;
          onProgress(step / 10);

          if (step >= 10 || signal.aborted) {
            clearInterval(timer);
            resolve();
          }
        }, 120);
      }),
  });

  return (
    <div className="flex w-full max-w-sm flex-col gap-3 text-sm">
      <div
        {...upload.getDropzoneProps()}
        data-dragging={upload.isDragging}
        className="flex flex-col items-center gap-2 rounded-lg border border-dashed border-border p-6 text-center transition-colors data-[dragging=true]:border-ring data-[dragging=true]:bg-muted/50"
      >
        <IconUpload className="size-5 text-muted-foreground" />
        <p className="text-xs text-muted-foreground">Drop images or PDFs here, up to 2 MB</p>
        <input {...upload.getInputProps()} />
        <Button size="xs" variant="outline" onClick={upload.openFileDialog}>
          Choose files
        </Button>
      </div>

      {upload.files.map((item) => (
        <div key={item.id} className="flex items-center gap-2 rounded-lg border border-border p-2">
          {item.previewUrl ? (
            <img src={item.previewUrl} alt="" className="size-8 rounded object-cover" />
          ) : (
            <div className="size-8 rounded bg-muted" />
          )}

          <div className="min-w-0 flex-1">
            <p className="truncate text-xs">{item.file.name}</p>
            <div className="mt-1 h-1 overflow-hidden rounded-full bg-muted">
              <div
                className="h-full bg-primary transition-[width] duration-200"
                style={{ width: `${item.progress * 100}%` }}
              />
            </div>
          </div>

          <span className="text-[0.7rem] text-muted-foreground">{formatBytes(item.file.size)}</span>

          <Button
            size="icon-xs"
            variant="ghost"
            aria-label={`Remove ${item.file.name}`}
            onClick={() => upload.removeFile(item.id)}
          >
            <IconX />
          </Button>
        </div>
      ))}

      {upload.rejections.map((rejection) => (
        <p key={`${rejection.file.name}-${rejection.reason}`} className="text-xs text-destructive">
          {rejection.message}
        </p>
      ))}
    </div>
  );
}
```


## Source

### hooks/use-file-upload.ts

```ts
"use client";

import * as React from "react";

type UploadStatus = "pending" | "uploading" | "success" | "error";

type UploadFile = {
  id: string;
  file: File;
  status: UploadStatus;
  /** Upload progress from 0 to 1. */
  progress: number;
  error: string | null;
  /** Object URL, present only for images. Revoked when the file is removed. */
  previewUrl: string | null;
};

type UploadRejection = {
  file: File;
  reason: "type" | "size" | "count" | "duplicate";
  message: string;
};

type UploadHandlerContext = {
  onProgress: (progress: number) => void;
  signal: AbortSignal;
};

type UseFileUploadOptions = {
  /** Same syntax as the `accept` attribute: `"image/*,.pdf"`. */
  accept?: string;
  /** Maximum size per file, in bytes. */
  maxSize?: number;
  maxFiles?: number;
  multiple?: boolean;
  disabled?: boolean;
  /** Upload transport. Resolve to finish, throw to fail. */
  onUpload?: (file: UploadFile, context: UploadHandlerContext) => Promise<void>;
  /** Start uploading as soon as files are accepted. Requires `onUpload`. */
  autoUpload?: boolean;
  onFilesChange?: (files: UploadFile[]) => void;
  onRejected?: (rejections: UploadRejection[]) => void;
};

function useFileUpload(options: UseFileUploadOptions = {}) {
  const {
    accept,
    maxSize,
    maxFiles,
    multiple = true,
    disabled = false,
    onUpload,
    autoUpload = false,
    onFilesChange,
    onRejected,
  } = options;

  const [files, setFiles] = React.useState<UploadFile[]>([]);
  const [rejections, setRejections] = React.useState<UploadRejection[]>([]);
  const [isDragging, setIsDragging] = React.useState(false);

  const inputRef = React.useRef<HTMLInputElement>(null);
  const dragDepth = React.useRef(0);
  const controllers = React.useRef(new Map<string, AbortController>());
  const filesRef = React.useRef(files);

  const callbacks = React.useRef({ onUpload, onFilesChange, onRejected });

  React.useEffect(() => {
    callbacks.current = { onUpload, onFilesChange, onRejected };
  });

  React.useEffect(() => {
    filesRef.current = files;
  }, [files]);

  React.useEffect(() => {
    const pending = controllers.current;

    return () => {
      for (const controller of pending.values()) controller.abort();
      for (const item of filesRef.current) {
        if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
      }
    };
  }, []);

  const commit = React.useCallback((next: UploadFile[]) => {
    filesRef.current = next;
    setFiles(next);
    callbacks.current.onFilesChange?.(next);
  }, []);

  const upload = React.useCallback(
    async (item: UploadFile) => {
      const handler = callbacks.current.onUpload;
      if (!handler) return;

      const controller = new AbortController();
      controllers.current.set(item.id, controller);

      const patch = (changes: Partial<UploadFile>) => {
        commit(
          filesRef.current.map((current) =>
            current.id === item.id ? { ...current, ...changes } : current,
          ),
        );
      };

      patch({ status: "uploading", progress: 0, error: null });

      try {
        await handler(item, {
          signal: controller.signal,
          onProgress: (progress) => patch({ progress: clamp(progress) }),
        });

        if (!controller.signal.aborted) patch({ status: "success", progress: 1 });
      } catch (error) {
        if (controller.signal.aborted) return;
        patch({ status: "error", error: toMessage(error) });
      } finally {
        controllers.current.delete(item.id);
      }
    },
    [commit],
  );

  const addFiles = React.useCallback(
    (incoming: Iterable<File>) => {
      if (disabled) return;

      const candidates = Array.from(incoming);
      const accepted: UploadFile[] = [];
      const refused: UploadRejection[] = [];

      let total = multiple ? filesRef.current.length : 0;

      for (const file of candidates) {
        const rejection = validateFile(file, {
          accept,
          maxSize,
          maxFiles,
          total,
          existing: multiple ? filesRef.current : [],
        });

        if (rejection) {
          refused.push(rejection);
          continue;
        }

        accepted.push(createUploadFile(file));
        total += 1;
      }

      if (!multiple) {
        for (const item of filesRef.current) {
          if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
        }
      }

      const next = multiple ? [...filesRef.current, ...accepted] : accepted.slice(0, 1);

      setRejections(refused);
      if (refused.length > 0) callbacks.current.onRejected?.(refused);
      if (accepted.length > 0 || !multiple) commit(next);

      if (autoUpload && callbacks.current.onUpload) {
        for (const item of next.filter((candidate) => candidate.status === "pending")) {
          void upload(item);
        }
      }
    },
    [accept, autoUpload, commit, disabled, maxFiles, maxSize, multiple, upload],
  );

  const removeFile = React.useCallback(
    (id: string) => {
      controllers.current.get(id)?.abort();
      controllers.current.delete(id);

      const item = filesRef.current.find((candidate) => candidate.id === id);
      if (item?.previewUrl) URL.revokeObjectURL(item.previewUrl);

      commit(filesRef.current.filter((candidate) => candidate.id !== id));
    },
    [commit],
  );

  const clearFiles = React.useCallback(() => {
    for (const controller of controllers.current.values()) controller.abort();
    controllers.current.clear();

    for (const item of filesRef.current) {
      if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
    }

    setRejections([]);
    commit([]);
  }, [commit]);

  const uploadAll = React.useCallback(async () => {
    const pending = filesRef.current.filter(
      (item) => item.status === "pending" || item.status === "error",
    );

    await Promise.all(pending.map((item) => upload(item)));
  }, [upload]);

  const retryFile = React.useCallback(
    (id: string) => {
      const item = filesRef.current.find((candidate) => candidate.id === id);
      if (item) void upload(item);
    },
    [upload],
  );

  const openFileDialog = React.useCallback(() => {
    if (!disabled) inputRef.current?.click();
  }, [disabled]);

  const getInputProps = React.useCallback(
    (): React.ComponentProps<"input"> => ({
      ref: inputRef,
      type: "file",
      accept,
      multiple,
      disabled,
      className: "sr-only",
      tabIndex: -1,
      onChange: (event) => {
        if (event.target.files) addFiles(event.target.files);
        event.target.value = "";
      },
    }),
    [accept, addFiles, disabled, multiple],
  );

  const getDropzoneProps = React.useCallback(
    (): React.ComponentProps<"div"> => ({
      onDragEnter: (event) => {
        event.preventDefault();
        if (disabled) return;
        dragDepth.current += 1;
        setIsDragging(true);
      },
      onDragOver: (event) => {
        event.preventDefault();
        if (!disabled) event.dataTransfer.dropEffect = "copy";
      },
      onDragLeave: (event) => {
        event.preventDefault();
        dragDepth.current = Math.max(0, dragDepth.current - 1);
        if (dragDepth.current === 0) setIsDragging(false);
      },
      onDrop: (event) => {
        event.preventDefault();
        dragDepth.current = 0;
        setIsDragging(false);
        if (!disabled && event.dataTransfer.files.length > 0) addFiles(event.dataTransfer.files);
      },
    }),
    [addFiles, disabled],
  );

  const isUploading = files.some((item) => item.status === "uploading");

  return {
    files,
    rejections,
    isDragging,
    isUploading,
    inputRef,
    addFiles,
    removeFile,
    clearFiles,
    retryFile,
    uploadAll,
    openFileDialog,
    getInputProps,
    getDropzoneProps,
  };
}

function createUploadFile(file: File): UploadFile {
  return {
    id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2, 8)}`,
    file,
    status: "pending",
    progress: 0,
    error: null,
    previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : null,
  };
}

function validateFile(
  file: File,
  context: {
    accept?: string;
    maxSize?: number;
    maxFiles?: number;
    total: number;
    existing: UploadFile[];
  },
): UploadRejection | null {
  const { accept, maxSize, maxFiles, total, existing } = context;

  if (maxFiles !== undefined && total >= maxFiles) {
    return { file, reason: "count", message: `Only ${maxFiles} files can be uploaded.` };
  }

  if (accept && !matchesAccept(file, accept)) {
    return { file, reason: "type", message: `${file.name} is not an accepted file type.` };
  }

  if (maxSize !== undefined && file.size > maxSize) {
    return {
      file,
      reason: "size",
      message: `${file.name} is larger than ${formatBytes(maxSize)}.`,
    };
  }

  const duplicate = existing.some(
    (item) =>
      item.file.name === file.name &&
      item.file.size === file.size &&
      item.file.lastModified === file.lastModified,
  );

  if (duplicate) {
    return { file, reason: "duplicate", message: `${file.name} was already added.` };
  }

  return null;
}

function matchesAccept(file: File, accept: string): boolean {
  const patterns = accept
    .split(",")
    .map((pattern) => pattern.trim().toLowerCase())
    .filter(Boolean);

  if (patterns.length === 0) return true;

  const type = file.type.toLowerCase();
  const name = file.name.toLowerCase();

  return patterns.some((pattern) => {
    if (pattern.startsWith(".")) return name.endsWith(pattern);
    if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
    return type === pattern;
  });
}

function formatBytes(bytes: number): string {
  const units = ["B", "KB", "MB", "GB"];
  let value = bytes;
  let unit = 0;

  while (value >= 1024 && unit < units.length - 1) {
    value /= 1024;
    unit += 1;
  }

  return `${value % 1 === 0 ? value : value.toFixed(1)} ${units[unit]}`;
}

function clamp(value: number): number {
  return Math.min(1, Math.max(0, value));
}

function toMessage(error: unknown): string {
  if (error instanceof Error) return error.message;
  return "Upload failed.";
}

export {
  useFileUpload,
  formatBytes,
  type UploadFile,
  type UploadRejection,
  type UploadStatus,
  type UseFileUploadOptions,
};
```



## Usage

Everything a file input needs that the platform does not give you: drag state, accept and size
validation with usable rejection messages, image previews that get revoked, per-file progress, and
retry. It renders nothing, so the same logic backs a dropzone, an avatar picker, or a paste handler.

```tsx
import { useFileUpload } from "@/hooks/use-file-upload";

function Uploader() {
  const upload = useFileUpload({
    accept: "image/*",
    maxSize: 5 * 1024 * 1024,
    maxFiles: 4,
  });

  return (
    <div {...upload.getDropzoneProps()} data-dragging={upload.isDragging}>
      <input {...upload.getInputProps()} />
      <button onClick={upload.openFileDialog}>Choose files</button>

      {upload.files.map((item) => (
        <p key={item.id}>{item.file.name}</p>
      ))}
    </div>
  );
}
```

## Uploading

Pass `onUpload` and the hook drives the transport, tracking status and progress per file. Resolve to
mark it uploaded; throw and the message lands on `item.error` with a retry available.

```tsx
const upload = useFileUpload({
  autoUpload: true,
  onUpload: async (item, { onProgress, signal }) => {
    const body = new FormData();
    body.append("file", item.file);

    await fetch("/api/upload", { method: "POST", body, signal });
    onProgress(1);
  },
});
```

`signal` aborts when the file is removed or the component unmounts, so a cancelled upload does not
keep streaming. For real byte-level progress use `XMLHttpRequest` — `fetch` cannot report upload
progress — and call `onProgress` with a fraction between `0` and `1`.

## Rejections

Files that fail validation never enter `files`. They arrive as `rejections`, each with a reason
(`"type"`, `"size"`, `"count"`, `"duplicate"`) and a message you can render as-is.

```tsx
{upload.rejections.map((rejection) => (
  <p key={rejection.file.name} className="text-destructive">{rejection.message}</p>
))}
```

Duplicates are matched on name, size, and last-modified time — the closest thing to a file identity
the browser offers.

## Options

| Option         | Description                                                                |
| -------------- | -------------------------------------------------------------------------- |
| `accept`       | Same syntax as the `accept` attribute: `"image/*,.pdf"`. Enforced on drop too, which the attribute alone does not do. |
| `maxSize`      | Per-file limit in bytes.                                                    |
| `maxFiles`     | Maximum number of accepted files.                                           |
| `multiple`     | When `false`, a new selection replaces the current file.                    |
| `disabled`     | Ignore selection and drops.                                                 |
| `onUpload`     | Upload transport. Receives `onProgress` and an `AbortSignal`.               |
| `autoUpload`   | Upload as soon as files are accepted.                                       |
| `onFilesChange`| Called with the full list after every change.                               |
| `onRejected`   | Called with the rejections from a single selection.                         |

## Returns

| Value                              | Description                                                     |
| ---------------------------------- | --------------------------------------------------------------- |
| `files`                            | `UploadFile[]` — `id`, `file`, `status`, `progress`, `error`, `previewUrl`. |
| `rejections`                       | Rejections from the most recent selection.                       |
| `isDragging` / `isUploading`       | Derived flags for styling.                                       |
| `addFiles` / `removeFile` / `clearFiles` | Imperative list control — `addFiles` also accepts a paste or drop `FileList`. |
| `uploadAll` / `retryFile`          | Trigger uploads when `autoUpload` is off.                        |
| `openFileDialog` / `inputRef`      | Open the native picker from your own trigger.                    |
| `getInputProps` / `getDropzoneProps` | Prop getters for the hidden input and the drop target.         |

`previewUrl` is an object URL created only for `image/*` files and revoked when the file is removed
or the component unmounts, so previews do not leak.

The `formatBytes` helper is exported alongside the hook for rendering file sizes.

