# react-mediadrop > A lightweight, headless-first file uploader for React # Introduction Source: https://www.mediadrop.dev/docs **react-mediadrop** is a headless, hooks-first file uploader for React with zero runtime dependencies. It handles intake, validation, and upload (queue, concurrency, retry, cancel) via a single `useMediaDrop` hook — the same `getRootProps`/`getInputProps` shape you already know from react-dropzone, with upload built in. Published to npm as [`react-mediadrop`](https://www.npmjs.com/package/react-mediadrop). You can use react-mediadrop for: - A drag-and-drop upload zone with your own markup and styling - Client-side file validation (type, size, count) before anything uploads - A queue that uploads multiple files with concurrency, retry, and cancel - Upload progress and per-file status rendered in your own UI ## Comparison react-mediadrop sits between react-dropzone and Uppy: the same headless API shape as react-dropzone, with the upload queue only Uppy otherwise offers. | Library | Model | Scope | | --- | --- | --- | | [react-dropzone](https://github.com/react-dropzone/react-dropzone) | Headless, hooks-first | Drag/drop and file intake only — no upload | | [Uppy](https://uppy.io) | Dashboard UI + plugin ecosystem | Upload via `xhr-upload`/`tus`/`aws-s3` plugins, remote-provider import via Companion | | [FilePond](https://pqina.nl/filepond) | Prebuilt widget | Styled, drop-in upload UI | | **react-mediadrop** | Headless, hooks-first | File intake, validation, and upload (queue, concurrency, retry, cancel) via one hook — **zero** runtime dependencies | Closest to react-dropzone in API shape — `useMediaDrop` returns the same `getRootProps`/`getInputProps`, plus the upload queue react-dropzone doesn't have. Closest to Uppy in upload scope — a pluggable [transport contract](/guides/custom-transport) instead of a plugin ecosystem — but without a dashboard, Companion, or remote-provider import; see [Roadmap](/roadmap) for what's not included. ## Headless-first You own the markup. `getRootProps`/`getInputProps` return plain props to spread onto whatever elements you already have — no prebuilt widget, no dashboard, nothing to override. Style it exactly like the rest of your app. ## What's here today Core handles file intake, drag/drop, and validation. Upload builds on top of it: a pluggable transport contract, a queue with concurrency/retry/ cancel, and a reference XHR transport. Not yet: pause/resume, remote-provider import, OAuth, image transforms, a prebuilt widget. See [Roadmap](/roadmap) for what's next. ## Which transport should I use? | Transport | Import | Request shape | Resumable? | | --- | --- | --- | --- | | XHR | `react-mediadrop/xhr-upload` | One request, whole file | No | If your files are small enough that a dropped connection losing all progress is acceptable, plain XHR covers it. See a minimal working dropzone The file model, the store, and drag state Restrictions and custom validators The queue, concurrency, retry, and cancel --- # Types Source: https://www.mediadrop.dev/docs/api-reference/types `react-mediadrop` re-exports every shared type — there's never a reason to import from an internal engine package directly. ## `MediaDropFile` ```ts type MediaDropFile = { id: string; file: File; name: string; size: number; type: string; lastModified?: number; status: "idle" | "accepted" | "rejected"; errors: MediaDropError[]; // Upload fields, only set once an upload is requested: uploadStatus?: "queued" | "uploading" | "done" | "error" | "canceled"; progress?: { loaded: number; total: number | null }; uploadError?: MediaDropError; uploadResult?: unknown; uploadAttempts?: number; }; ``` ## `MediaDropRestrictions` ```ts type MediaDropRestrictions = { maxFiles?: number; minSize?: number; // bytes maxSize?: number; // bytes accept?: string[] | string; // mime types, wildcards, or extensions }; ``` ## `MediaDropError` ```ts type MediaDropErrorCode = | "file-invalid-type" | "file-too-large" | "file-too-small" | "too-many-files" | "validator-error" | "upload-error"; type MediaDropError = { code: MediaDropErrorCode; message: string; status?: number; // HTTP status, upload errors only sourceCode?: string; // transport-specific, upload errors only }; ``` ## `MediaDropValidator` ```ts type MediaDropValidator = ( file: File, ) => MediaDropError | MediaDropError[] | null | undefined; ``` ## `DragState` ```ts type DragState = { isDragActive: boolean; isDragAccept: boolean; isDragReject: boolean; }; ``` ## `UploadTransport` ```ts type UploadTransport = { upload( file: MediaDropFile, context: { onProgress: (progress: { loaded: number; total: number | null }) => void; signal: AbortSignal; }, ): Promise<{ response?: unknown }>; }; ``` ## `RetryOptions` and `withRetry` ```ts type RetryOptions = { retries?: number; retryDelays?: number[]; shouldRetry?: (error: unknown, attemptNumber: number) => boolean; jitter?: number; // 0–1 }; function withRetry( attempt: (attemptNumber: number) => Promise, options: RetryOptions, signal: AbortSignal, ): Promise; ``` ## Session persistence Exported for custom resumable transports — no bundled transport currently uses them. ```ts type MediaDropUploadSessionStore = { get(key: string): Promise; set(key: string, value: unknown): Promise; remove(key: string): Promise; }; function createMemoryUploadSessionStore(): MediaDropUploadSessionStore; function createBrowserUploadSessionStore(options?: { prefix?: string; }): MediaDropUploadSessionStore; function createFileFingerprint(file: File): string; ``` The file model, the store, and drag state Full hook reference --- # useMediaDrop Source: https://www.mediadrop.dev/docs/api-reference/use-media-drop ```ts import { useMediaDrop } from "react-mediadrop"; ``` Headless hook. No prebuilt component — you own the markup. ## Options ```ts function useMediaDrop(options?: { restrictions?: MediaDropRestrictions; validator?: MediaDropValidator; noClick?: boolean; // disable click-to-open on the root noKeyboard?: boolean; // disable Space/Enter-to-open and focus tracking noDrag?: boolean; // disable the root's drag/drop handling transport?: UploadTransport; // opt into upload — see below concurrency?: number; // max uploads in flight at once. Default 1. retries?: number; // retries after the first attempt. Default 0. retryDelays?: number[]; // backoff per retry. cancelGraceMs?: number; // force-free a slot after cancel. Default 5000. }): UseMediaDropResult; ``` ## Return value | Field | Type | Notes | | --- | --- | --- | | `files` / `acceptedFiles` / `rejectedFiles` | `MediaDropFile[]` | Every file added, filtered by `status`. | | `isDragActive` / `isDragAccept` / `isDragReject` | `boolean` | Per-dropzone drag state — best-effort, see [Core concepts](/guides/core-concepts#how-do-i-know-when-a-file-is-being-dragged). | | `isFocused` | `boolean` | Root element has keyboard focus. Always `false` when `noKeyboard` is set. | | `isDragGlobal` | `boolean` | A file drag is happening anywhere on the document, not just this root. | | `removeFile(id)` / `clearFiles()` | `() => void` | Mutate the file list. Cancels any in-flight upload for removed files. | | `open()` | `() => void` | Imperatively open the native file picker. | | `getRootProps(arg?)` | `() => RootProps` | Drag/drop + click/keyboard handlers, `role`, `tabIndex`. | | `getInputProps(arg?)` | `() => InputProps` | Hidden `` props. | Passing `transport` additionally returns `uploadFile`/`uploadAll`/ `cancelUpload`/`cancelAllUploads`/`retryUpload` — see [Upload](/guides/upload). Without `transport`, none of it exists on the returned object, and TypeScript won't let you call it. ### `getRootProps`/`getInputProps` Both accept and pass through arbitrary HTML attributes alongside the recognized handlers — useful for `aria-label`/`aria-describedby`/ `className`/`id`/`data-*`. A consumer-supplied `style` on `getInputProps()` is merged, but `display: none` always wins — it can't be overridden, since the hidden native input is load-bearing for click-to-open. ```tsx
``` They compose with your own handlers — yours always runs first: ```tsx
console.log("also dropped", e) })}> ``` If your handler calls `event.stopPropagation()`, the hook's own handling for that event is skipped. This is the one supported way to override built-in behavior. ### Click-to-open and keyboard activation By default, `getRootProps()` makes the root element click- and keyboard-activatable: clicking anywhere in the root, or pressing Space/Enter while it's focused, opens the native file picker — the same thing `open()` does programmatically. - `noClick: true` disables click-to-open (keep `open()` for a manual "Choose files" button instead). - `noKeyboard: true` disables Space/Enter-to-open, removes `tabIndex` from the returned props, and stops tracking `isFocused`. - `noDrag: true` disables drag/drop handling on the root entirely — the input and click-to-open still work. If you render your own "Choose files" button inside the root element, stop its click from bubbling to the root, or click-to-open fires a second time: ```tsx
``` ## Things to get right - Don't wrap the returned `` in `display: none` yourself and expect `open()` to fail — `getInputProps()` already hides it and `open()` calls `.click()` on it programmatically, which works through `display: none` in all evergreen browsers. - The hook never touches `window`/`document` during render, so it's SSR-safe. `isDragGlobal`'s `document` listeners are registered inside a `useEffect` (client-only) and removed on unmount. You can render it on the server without guards. - The engine backing one `useMediaDrop()` call is created once for that component instance's lifetime. Changing `restrictions`/`validator` props after mount changes future drag-acceptance previews but does **not** retroactively re-validate files already in `files`. - Multiple `useMediaDrop()` calls on the same page are independent and safe — see [Core concepts](/guides/core-concepts#can-i-use-multiple-dropzones-on-one-page). - There's no dashboard/progress UI to import — every list item, remove button, progress bar, and status message is yours to build. The file model, the store, and drag state Every shared type exported from react-mediadrop --- # xhr-upload Source: https://www.mediadrop.dev/docs/api-reference/xhr-upload ```ts import { createXhrUploadTransport } from "react-mediadrop/xhr-upload"; ``` The reference `UploadTransport` — sends a file with `XMLHttpRequest`, not `fetch`, specifically because `fetch` still has no cross-browser upload-progress API while `XMLHttpRequest.upload.onprogress` does. ## Quickstart ```ts import { useMediaDrop } from "react-mediadrop"; import { createXhrUploadTransport } from "react-mediadrop/xhr-upload"; const transport = createXhrUploadTransport({ endpoint: "/api/upload", // or (file) => `/api/upload/${file.id}` for a per-file URL fields: { folder: "avatars" }, // extra multipart fields }); const { files } = useMediaDrop({ transport, concurrency: 3, retries: 2 }); ``` ## Options | Option | Type | Default | Notes | | --- | --- | --- | --- | | `endpoint` | `string \| (file) => string` | — required | Computed per file, e.g. for a per-file presigned URL you already fetched. | | `method` | `string` | `"POST"` | | | `fieldName` | `string` | `"file"` | Ignored when `formData: false`. | | `fields` | `object \| (file) => object` | — | Extra multipart fields. Ignored when `formData: false`. | | `headers` | `object \| (file) => object` | — | | | `withCredentials` | `boolean` | `false` | | | `formData` | `boolean` | `true` | `false` sends the raw file body. | | `isSuccessStatus` | `(status) => boolean` | `200–299` | | | `stallTimeoutMs` | `number` | `0` (disabled) | Abort and reject if no upload progress happens for this long — a *stall* timeout (reset on every progress tick), not a flat total-duration one, so a large file on a slow-but-healthy connection is never falsely aborted. | ## What this is for, and what it isn't - Use it for a generic REST-ish endpoint you control — a single request, the whole file, `multipart/form-data` by default (`formData: false` sends the raw bytes, e.g. for a presigned PUT URL that isn't S3's). - Its only dependency is the underlying engine itself; it has **no retry loop** and **no concurrency control** of its own — both are the queue's job (see [Upload](/guides/upload)). Don't add retry logic here if asked to "make uploads more resilient"; point at `retries`/`retryDelays` on `useMediaDrop` instead. - It has **no flat request timeout** by design — `stallTimeoutMs` aborts on *no progress*, not on total duration. It's disabled (`0`) by default; set it if a task needs "don't hang forever on a dead connection." - It has **no resumability** — a failed or canceled upload restarts from byte zero. - `formData: false` still sends one request, one body — it does not split a file into parts. Need something else — a provider SDK, a different protocol, a test double? See [Writing a custom transport](/guides/custom-transport). The queue, concurrency, retry, and cancel Implement the transport contract yourself --- # Changelog Source: https://www.mediadrop.dev/docs/changelog ## 0.1.1 ### Patch - Updated package READMEs (`react-mediadrop`, `@mediadrop/core`, `@mediadrop/xhr-upload`) to match the current docs — install steps, quickstart, entry points. ## 0.1.0 ### Initial release - `useMediaDrop` — headless file intake: drag/drop + picker, sync validation (`accept`/`maxFiles`/`minSize`/`maxSize` + custom validator), typed `MediaDropError`s, best-effort `isDragAccept`/`isDragReject` drag state. - Upload (opt-in via `transport`): a pluggable transport contract, a queue with concurrency/retry/cancel, and per-file `uploadStatus`/`progress`/ `uploadError`/`uploadResult`. Without a transport, nothing changes about the existing file intake/validation behavior above. - `react-mediadrop/xhr-upload` — reference `XMLHttpRequest` transport, tree-shakeable as a separate entry point so consumers who don't import it never bundle it. - `@mediadrop/core` bundled directly into `react-mediadrop`'s dist — one package to install, no separate core dependency. See the [full changelog](https://github.com/autorender/react-mediadrop/blob/main/packages/react/CHANGELOG.md) in the source repo. --- # Accepting file types Source: https://www.mediadrop.dev/docs/examples/accept import acceptCode from "../../../examples-source/accept.tsx?raw"; Restrict the dropzone to specific file types with `restrictions.accept`. `accept` takes an exact MIME type (`"image/png"`), a wildcard (`"image/*"`), a file extension (`".png"`), or an array/comma-separated string of any of those. See [Validation](/guides/validation) for the full shape. --- # Minimal setup Source: https://www.mediadrop.dev/docs/examples/basic import basicCode from "../../../examples-source/basic.tsx?raw"; The minimal setup: file intake and drag state, no validation and no upload transport. --- # Cancel and retry Source: https://www.mediadrop.dev/docs/examples/cancel-retry import cancelRetryCode from "../../../examples-source/cancel-retry.tsx?raw"; `cancelUpload(id)` aborts an in-flight upload via `AbortSignal` and ends in `uploadStatus: "canceled"`. `retryUpload(id)` re-enqueues a file, but only once its last attempt ended in `uploadStatus: "error"`. :::info This page's transport is simulated (4-second uploads, no live backend) so there's time to click Cancel mid-flight and to force a failure to retry. The API below is real — see [Upload](/guides/upload#how-do-i-configure-concurrency-retry-and-cancel) for the full `cancelUpload`/`retryUpload` contract. ::: Retrying stops immediately once a file is canceled — a cancel always wins over a pending retry. This manual `retryUpload` is separate from the automatic `retries`/`retryDelays` config, which runs before a file ever reaches `uploadStatus: "error"` — see [Upload](/guides/upload#the-shared-retry-engine). --- # Concurrency limit Source: https://www.mediadrop.dev/docs/examples/concurrency import concurrencyCode from "../../../examples-source/concurrency.tsx?raw"; Pass `concurrency` to `useMediaDrop({ transport, concurrency })` to cap how many uploads run at once. Every file beyond that cap sits at `uploadStatus: "queued"` until a slot frees up. :::info This page's transport is simulated, so multiple files can be dropped at once with no live backend to send them to. The `concurrency` option below is real — see [Upload](/guides/upload#how-do-i-configure-concurrency-retry-and-cancel). ::: Drop more files than the `concurrency` cap and only that many ever show `"uploading"` at once — the rest hold at `"queued"` until a slot frees up, then the queue picks the next one automatically. --- # Drag states Source: https://www.mediadrop.dev/docs/examples/drag-states import dragStatesCode from "../../../examples-source/drag-states.tsx?raw"; `useMediaDrop` returns five drag/focus flags. Drag an image over the zone below to watch them flip live. `isDragAccept`/`isDragReject` are best-effort — browsers only expose a dragged file's MIME type, not its name, until drop. An extension-based `accept` (like `.png`) can't be evaluated mid-drag, so use MIME types (`image/*`) if you want this preview to work. `isDragGlobal` tracks drags anywhere on the document, not just this dropzone's root. See [Core concepts](/guides/core-concepts#how-do-i-know-when-a-file-is-being-dragged). --- # Error codes Source: https://www.mediadrop.dev/docs/examples/error-codes Every restriction, validator, and upload failure produces a `MediaDropError` — a stable `code` plus a human `message` — so you can switch on `code` instead of parsing `message` text. | Code | Fires when | Appears on | | --- | --- | --- | | `file-invalid-type` | file doesn't match `restrictions.accept` | `file.errors` | | `file-too-large` | `file.size > restrictions.maxSize` | `file.errors` | | `file-too-small` | `file.size < restrictions.minSize` | `file.errors` | | `too-many-files` | selection exceeds `restrictions.maxFiles` | `file.errors` | | `validator-error` | your `validator` function returned an error | `file.errors` | | `upload-error` | the transport's `upload()` rejected | `file.uploadError` | This demo accepts images only, 1 KB–2 MB, max 3 files, and rejects filenames with spaces — try tripping each rule. Toggle the checkbox to force the next upload to fail with `upload-error`. ```tsx import { useMediaDrop } from "react-mediadrop"; const { files } = useMediaDrop({ restrictions: { accept: "image/*", minSize: 1024, maxSize: 2_000_000, maxFiles: 3 }, validator: (file) => file.name.includes(" ") ? { code: "validator-error", message: "Filenames can't contain spaces" } : null, transport, // rejects to produce uploadError: { code: "upload-error", ... } }); for (const file of files) { for (const error of file.errors) { console.log(error.code, error.message); } if (file.uploadError) { console.log(file.uploadError.code, file.uploadError.message); } } ``` `upload-error` additionally carries `status` (the HTTP status, when the transport threw via `createHttpError`) and `sourceCode` (a transport-specific code, when the transport attached one) — both omitted when not applicable. See [Types](/api-reference/types#mediadroperror) for the full shape. --- # Event propagation Source: https://www.mediadrop.dev/docs/examples/events import eventsCode from "../../../examples-source/events.tsx?raw"; Passing your own `onDrop` to `getRootProps()` runs before react-mediadrop's own drop handling. Call `event.stopPropagation()` inside it and the default handling — adding the dropped files — never runs. The same applies to `onClick`, `onKeyDown`, `onFocus`, and `onBlur` passed to `getRootProps()`, and `onChange`/`onClick` passed to `getInputProps()` — your handler runs first, and calling `stopPropagation()` on the event skips react-mediadrop's own handling for that interaction. --- # Opening the file dialog Source: https://www.mediadrop.dev/docs/examples/file-dialog import fileDialogCode from "../../../examples-source/file-dialog.tsx?raw"; Open the native file picker from your own button, instead of the dropzone's default click-to-open. `noClick` and `noKeyboard` turn off the dropzone's own click-to-open and Space/Enter-to-open behavior, so `open()` is the only way in. Most browsers require the call to originate from a direct user interaction like a click — calling it from an effect or a timer won't open the picker. --- # Using it inside a form Source: https://www.mediadrop.dev/docs/examples/forms import formsCode from "../../../examples-source/forms.tsx?raw"; `react-mediadrop`'s files live in its own store, not in a native `` — a plain form submission won't include them unless you mirror them into a hidden input yourself. Each `MediaDropFile` carries the original browser `File` on `.file` — that's what gets added to the hidden input's `DataTransfer`. On submit, the hidden input's `FileList` goes out with the rest of the form fields. --- # Max files Source: https://www.mediadrop.dev/docs/examples/max-files import maxFilesCode from "../../../examples-source/max-files.tsx?raw"; Cap how many files a dropzone accepts with `restrictions.maxFiles`. `maxFiles` is evaluated across the whole file list, not per file. Drop a batch bigger than the remaining slots and files fill the remaining slots in order — the overflow is rejected with `too-many-files`, not the whole batch. See [Core concepts](/guides/core-concepts#how-does-maxfiles-work-across-a-batch). --- # Presigned URL upload Source: https://www.mediadrop.dev/docs/examples/presigned-url import presignedUrlCode from "../../../examples-source/presigned-url.tsx?raw"; `createXhrUploadTransport`'s `endpoint` can be a function of the file, but it's synchronous — it can't `await` a request to your server. Resolve each file's presigned URL first, then call `uploadFile(id)` once you have it. :::info This page's transport is simulated — there's no live backend on this docs site, and no S3 bucket behind it. The pattern below is real and provider-agnostic: it works the same for S3, GCS, or Azure SAS URLs, or any endpoint your own server signs. ::: `formData: false` sends the file's raw bytes as the request body — a single PUT/POST, not a multipart upload (multiple parts, an upload ID, a completion call). For anything else `createXhrUploadTransport` doesn't cover, see [Writing a custom transport](/guides/custom-transport). --- # Styling Source: https://www.mediadrop.dev/docs/examples/styling import stylingCode from "../../../examples-source/styling.tsx?raw"; `getRootProps()`/`getInputProps()` set no `className` or `style` of their own — you're in full control, and anything you pass through is merged in. A `style`/`className` you pass to `getRootProps()` or `getInputProps()` is merged with what the hook needs internally — the one exception is `getInputProps()`'s hidden-input `display: none`, which always wins, since the hidden-native-input pattern breaks without it. --- # Upload progress Source: https://www.mediadrop.dev/docs/examples/upload-progress import uploadProgressCode from "../../../examples-source/upload-progress.tsx?raw"; Call `uploadFile(id)` as soon as a file is accepted, then read `progress` and `uploadStatus` off each `MediaDropFile` to render a bar. :::info This page's transport is simulated — there's no live backend on this docs site. The API below is real: swap in `createXhrUploadTransport()` (see [xhr-upload](/api-reference/xhr-upload)) or your own transport (see [Writing a custom transport](/guides/custom-transport)) and nothing else changes. ::: `progress` may be `undefined` before upload begins, and `total` may be `null` until the transport reports it. Fall back to the file's own `size` for `max`. See [Upload](/guides/upload#what-upload-fields-does-mediadropfile-have) for the full field list. --- # Custom validation Source: https://www.mediadrop.dev/docs/examples/validator import validatorCode from "../../../examples-source/validator.tsx?raw"; Reject files with a `validator` function for rules `restrictions` can't express. Return `null`/`undefined` to accept, or one error / an array of errors to reject. Don't reimplement `accept`/`maxSize`/`minSize` here — use `restrictions` for those. See [Validation](/guides/validation) for the full contract. --- # Agent skill Source: https://www.mediadrop.dev/docs/getting-started/agent-skill react-mediadrop ships an [Agent Skill](https://skills.sh) — a reference doc your AI coding agent reads before it writes code against this package, so it gets the file model, the transport contract, and the retry engine right on the first try instead of guessing from the package name. ## Install the skill ```sh npx skills add autorender/react-mediadrop ``` This pulls `skills/mediadrop` from the [react-mediadrop repo](https://github.com/autorender/react-mediadrop) into your project so any Agent Skills-compatible tool (Claude Code, Cursor, Copilot) picks it up automatically. ## What's in the skill - **`SKILL.md`** — when to use it, the mental model, and links to every reference below. - **`references/core-concepts.md`** — the file model, the store, drag state. - **`references/validation.md`** — restrictions and custom validators. - **`references/upload.md`** — the queue, concurrency, retry, and cancel contract. - **`references/xhr-upload.md`** — the reference transport. - **`references/react.md`** — the `useMediaDrop` hook surface. - **`references/registry-blocks.md`** — the four prebuilt shadcn-registry blocks (dropzone, avatar uploader, multi-file upload form, S3 direct-upload) and when to install one instead of hand-rolling markup. - **`references/scope.md`** — what's deliberately not implemented yet, so your agent doesn't invent a pause/resume or OAuth API that isn't there. - **`references/troubleshooting.md`** — the same integration mistakes covered in [Installation](/getting-started/installation) and [Core concepts](/guides/core-concepts), written for an agent to check against. :::info The skill describes the same package as this site — same types, same defaults, same limitations. If you ever see the two disagree, that's a docs bug: open an issue on the [repo](https://github.com/autorender/react-mediadrop). ::: ## Context7 This site is also indexed on [Context7](https://context7.com/autorender/react-mediadrop), so MCP-enabled tools (Cursor, Claude Code, Windsurf, and others) can pull these docs into context without you installing anything. Add the package to your project See a minimal working dropzone --- # Installation Source: https://www.mediadrop.dev/docs/getting-started/installation react-mediadrop ships as a single npm package — no separate core or transport package to install. Purpose: integrate react-mediadrop correctly on the first try. **Setup** ``` npm install react-mediadrop # Requires React 18+. # In Next.js App Router (or any RSC setup), add "use client" to the # top of the file that calls useMediaDrop — it uses # useSyncExternalStore/useEffect and only runs in a Client Component. import { useMediaDrop } from "react-mediadrop"; import { createXhrUploadTransport } from "react-mediadrop/xhr-upload"; ``` **useMediaDrop(options?) — full option reference** | Option | Type | Default | | --- | --- | --- | | restrictions | MediaDropRestrictions | — | | validator | MediaDropValidator | — | | noClick | boolean | false | | noKeyboard | boolean | false | | noDrag | boolean | false | | transport | UploadTransport | — (opts into upload) | | concurrency | number | 1 | | retries | number | 0 | | retryDelays | number[] | — | | cancelGraceMs | number | 5000 | `MediaDropRestrictions: { maxFiles?, minSize?, maxSize?, accept?: string[] | string }` Return value (always present): files / acceptedFiles / rejectedFiles, isDragActive / isDragAccept / isDragReject, isFocused, isDragGlobal, removeFile(id), clearFiles(), open(), getRootProps(), getInputProps(). Return type name: UseMediaDropResult. Only present when `transport` is passed: uploadFile(id), uploadAll(), cancelUpload(id), cancelAllUploads(), retryUpload(id). Without `transport`, none of these exist on the returned object and TypeScript will not let you call them. **createXhrUploadTransport(options) — the reference transport** | Option | Type | Default | | --- | --- | --- | | endpoint | string or (file) => string | required | | method | string | "POST" | | fieldName | string | "file" | | fields | object or (file) => object | — | | headers | object or (file) => object | — | | withCredentials | boolean | false | | formData | boolean | true | | isSuccessStatus | (status) => boolean | 200-299 | | stallTimeoutMs | number | 0 (disabled) | **ALWAYS DO** 1. ALWAYS add "use client" at the top of any file calling useMediaDrop in a Next.js App Router / RSC project. 2. ALWAYS import from "react-mediadrop" and "react-mediadrop/xhr-upload" only. Never import from @mediadrop/core or @mediadrop/xhr-upload — those are private, unpublished packages bundled inside react-mediadrop. 3. ALWAYS pass `transport` before calling uploadFile/uploadAll/ cancelUpload/cancelAllUploads/retryUpload. 4. ALWAYS write a custom transport as exactly one method, `upload(file, { onProgress, signal }) => Promise<{ response?: unknown }>`. 5. ALWAYS call the shared withRetry export for any retry logic a custom transport needs of its own. **NEVER DO** 1. NEVER assume pause/resume, a remote-provider import, OAuth, or a prebuilt upload widget exist — none are implemented. 2. NEVER add a retry loop, backoff, or concurrency limit inside a custom transport or inside useMediaDrop's upload methods — the queue and withRetry already own that. 3. NEVER assume the default shouldRetry retries every failure — it retries only 408, 429, 5xx, and statusless (network) errors; other 4xx statuses are not retried. 4. NEVER treat createFileFingerprint as content-addressed — it hashes name+size+type+lastModified+webkitRelativePath, not file bytes, so two different files with identical metadata can collide. 5. NEVER name the hook's return type MediaDropResult — the real exported name is UseMediaDropResult. **Verification checklist** Before returning any solution, verify: - [ ] "use client" is present if this file runs in a Next.js App Router tree. - [ ] transport is passed to useMediaDrop before any upload method is called. - [ ] restrictions only use maxFiles/minSize/maxSize/accept — no invented options. - [ ] No retry/backoff/concurrency logic exists inside a custom transport. - [ ] Run `tsc` — it must reject uploadFile/uploadAll/etc. calls when transport was not passed. Full reference: https://github.com/autorender/react-mediadrop/tree/main/skills/mediadrop :::note Zero runtime dependencies — a peer dependency on **React 18+** is the only thing you pull in. ::: ```package-install react-mediadrop ``` ## Next.js / RSC :::warning `useMediaDrop` uses `useSyncExternalStore`/`useEffect`, so it only runs in a Client Component. Forgetting this is the single most common integration mistake — if you hit an error like: ``` Error: You're importing a component that needs `useState`. It only works in a Client Component but none of its parents are marked with "use client", so they're Server Components by default. ``` add `"use client"` to the top of the file that calls `useMediaDrop`: ::: ```tsx "use client"; import { useMediaDrop } from "react-mediadrop"; ``` See a minimal working dropzone Let your AI coding agent integrate this package for you --- # Core concepts Source: https://www.mediadrop.dev/docs/guides/core-concepts `react-mediadrop` tracks every file you add as a `MediaDropFile` object, backed by a small internal store you never call directly. The engine that owns this store is bundled inside `react-mediadrop` — it isn't published or imported separately. ## What does a file look like? Every file you add becomes a `MediaDropFile`: ```ts type MediaDropFile = { id: string; // generated, not the browser File's name file: File; // the original browser File name: string; size: number; type: string; lastModified?: number; status: "idle" | "accepted" | "rejected"; errors: MediaDropError[]; // always an array, even when empty // Upload fields, only ever set once an upload is requested for this file: uploadStatus?: "queued" | "uploading" | "done" | "error" | "canceled"; progress?: { loaded: number; total: number | null }; uploadError?: MediaDropError; // present after a failed/canceled attempt uploadResult?: unknown; // whatever the transport resolved with uploadAttempts?: number; }; ``` A file's `status` is decided once, when it's added — there's no re-validation pass later, and **uploading never changes it**. `status` (validation) and `uploadStatus` (upload lifecycle) are deliberately separate fields: `getAcceptedFiles()`/`getRejectedFiles()` and `maxFiles` counting are all based on `status` alone and are completely unaffected by whether a file has started, finished, or failed uploading. See [Upload](/guides/upload#status-and-uploadstatus-stay-separate). ## How does the store work? The engine backing `useMediaDrop` returns an object backed by a small store: ```ts engine.getState(); // { files: MediaDropFile[] } engine.subscribe(listener); // full-state subscription engine.subscribe(selector, listener); // fires only when selector's result changes ``` `react-mediadrop` wraps this with `useSyncExternalStore` internally — you don't call `subscribe` yourself in React, you just read the hook's return value. ## How do I know when a file is being dragged? ```ts type DragState = { isDragActive: boolean; // a drag payload is over this dropzone right now isDragAccept: boolean; // best-effort: payload looks acceptable isDragReject: boolean; // best-effort: payload looks unacceptable }; ``` `isDragAccept`/`isDragReject` are evaluated from `DataTransferItem.type` during `dragenter`. :::info Browsers don't expose the file name while dragging — only after drop. So an `accept` restriction like `[".png"]` (an extension) can't be evaluated mid-drag, and both flags stay `false` in that case. MIME-based restrictions (`"image/png"`, `"image/*"`) work during drag, since the MIME type *is* available on `DataTransferItem`. Use MIME types in `accept` if you want an accurate drag-time preview. ::: A custom `validator` also participates in this preview, but only as far as the browser lets it: some browsers hand back a real (if empty/unreadable) `File` from `DataTransferItem.getAsFile()` mid-drag, and when that happens the validator runs against it. When the browser doesn't expose that, the validator is silently skipped for the preview — it still runs for real at drop time either way. Don't assume the validator ran during drag; treat it as a bonus, not a guarantee. `isDragActive`/`isDragAccept`/`isDragReject` are per-dropzone — there's no page-wide equivalent by default. `useMediaDrop` additionally returns `isDragGlobal`: true while a file drag is anywhere on the document, tracked via its own `dragenter`/`dragleave`/`dragend`/`drop` listeners on `document` (cleaned up on unmount). Don't assume an equivalent exists outside of it; wire your own `document` listeners if you need the same thing somewhere else. ## How does `maxFiles` work across a batch? Individual restrictions (`accept`, `minSize`, `maxSize`) are evaluated per file — one bad file never blocks the others in the same batch. `maxFiles` is different: it's evaluated across the whole file list. When you call `addFiles` with a batch that would exceed the remaining slots, files fill the remaining slots **in order** and the overflow is rejected with `too-many-files`. It does not reject the entire batch. ## Can I use multiple dropzones on one page? Each `useMediaDrop()` call owns its own drag-enter/leave depth counter and only reacts to events that bubble to its own root element: - Multiple independent dropzones on one page do not interfere with each other. - Moving the pointer across child elements inside one dropzone does not cause `isDragActive` to flicker (a counter, not a boolean flip, tracks enter/leave). - Overlapping/nested dropzones are **not** specially coordinated today — a known limitation, not yet solved. Restrictions and custom validators The queue, concurrency, retry, and cancel --- # Writing a custom transport Source: https://www.mediadrop.dev/docs/guides/custom-transport `react-mediadrop/xhr-upload` covers a generic REST-ish endpoint. For anything else — a provider SDK, a test double, a more advanced protocol — write your own transport against the same contract: ```ts type UploadTransport = { upload( file: MediaDropFile, context: { onProgress: (progress: { loaded: number; total: number | null }) => void; signal: AbortSignal; }, ): Promise<{ response?: unknown }>; }; ``` ## What must a transport do? - **One file, one attempt.** Send the file, report progress, resolve or reject. The queue decides whether to retry — you don't. - **Call `onProgress` as the upload progresses.** Use `total: null` when the length can't be determined. - **Wire `signal`'s `abort` event to whatever cancellation your transport has** (e.g. `XMLHttpRequest.abort()`, `fetch`'s own `signal` support). - **Resolve with `{ response }`** — anything, opaque to the engine (e.g. the server's parsed JSON body) — on success. Reject on failure. - **Do not implement your own retry or backoff inside the transport.** Use the shared `withRetry` engine (re-exported from `react-mediadrop`) for any finer-grained retry the transport itself needs — see [Upload](/guides/upload#the-shared-retry-engine). This is a direct reaction to a real anti-pattern: libraries that let every transport carry its own independent copy of retry/backoff logic end up with subtly different retry behavior per transport, and no single place to fix a bug in it. react-mediadrop has one retry engine, and this is where you plug into it. - **Do not implement your own concurrency limit.** The queue decides how many uploads run at once; your transport just services one call at a time when asked. ## What does a minimal transport look like? ```ts import { createHttpError, type UploadTransport } from "react-mediadrop"; function createMyTransport(options: { endpoint: string }): UploadTransport { return { async upload(file, { onProgress, signal }) { const response = await fetch(options.endpoint, { method: "POST", body: file.file, signal, }); if (!response.ok) { throw createHttpError(`Upload failed: ${response.status}`, response.status); } onProgress({ loaded: file.size, total: file.size }); return { response: await response.json() }; }, }; } ``` This example reports progress only once, at completion, because `fetch` has no cross-browser upload-progress API — this is exactly why `react-mediadrop/xhr-upload` uses `XMLHttpRequest` instead. If your task needs real incremental progress, use `XMLHttpRequest.upload.onprogress` the way the reference transport does — see its [API reference](/api-reference/xhr-upload). ## What about multi-request transports? A transport that splits one file into several requests is still "one file, one `upload()` call" from the queue's point of view — internally it may issue many requests and retry individual ones via the shared `withRetry`, called again for that finer-grained retry, but never a second, hand-rolled retry implementation. The queue, concurrency, retry, and cancel The file model, the store, and drag state --- # Upload Source: https://www.mediadrop.dev/docs/guides/upload Upload adds a queue, concurrency, retry, and cancel on top of Core's file intake, behind a pluggable transport contract — `react-mediadrop/xhr-upload` ships as the reference transport. This still does **not** add pause/resume, a remote-provider/OAuth story, or a widget. ## How does upload work? 1. You pick a transport — a small object with one method, `upload(file, { onProgress, signal })`. `createXhrUploadTransport()` is the reference implementation. You can write your own for anything else (a provider SDK, a test double, a more advanced resumable protocol). 2. You pass that transport to `useMediaDrop({ transport, ... })` — the exact same option regardless of which transport it is. 3. Only then do `uploadFile`/`uploadAll`/`cancelUpload`/ `cancelAllUploads`/`retryUpload` exist on the returned object — without `transport`, they are absent, and TypeScript will not let you call them. This mirrors Core's own restraint: no feature exists halfway. 4. Every file's upload progress lives on the `MediaDropFile` itself (`uploadStatus`, `progress`, `uploadError`, `uploadResult`, `uploadAttempts`) — you read it the same way you already read `status`/`errors`, via `files`. ## Where does retry and concurrency logic live? `react-mediadrop` owns all upload orchestration. A transport's job is exactly one thing: send one file, once, report progress, resolve or reject. It has **no retry loop and no concurrency limit of its own** — both are the queue's job. `useMediaDrop`'s upload methods are thin pass-throughs to the same queue. Writing your own transport? Retry and concurrency stay out of it — see [Writing a custom transport](/guides/custom-transport) for the shared `withRetry` engine it should call into instead. ### The shared retry engine `withRetry(attempt, options, signal)` is the one retry engine, used by the queue and available to any transport that needs finer-grained retry of its own: ```ts type RetryOptions = { retries?: number; // retries after the first attempt. Default 0. retryDelays?: number[]; // backoff per retry; last value repeats if exhausted. shouldRetry?: (error: unknown, attemptNumber: number) => boolean; // default: defaultShouldRetry jitter?: number; // 0–1, randomizes each delay by up to this fraction. Default 0. }; ``` `defaultShouldRetry`, the built-in default, retries **408, 429, and every 5xx** status, plus anything without a recognizable HTTP status (network errors) — it does not retry other 4xx statuses (400/401/403/404/413, etc.), since those describe a request that fails the same way every time. Pass your own `shouldRetry` to override this classification entirely. `jitter` matters when many requests could fail at once (e.g. every part of a multipart upload hitting the same transient network issue) — it spreads their retries out instead of having them all retry in lockstep. ## What upload fields does `MediaDropFile` have? ```ts type MediaDropFile = { // ...status, errors, etc. — unchanged from Core... uploadStatus?: "queued" | "uploading" | "done" | "error" | "canceled"; progress?: { loaded: number; total: number | null }; uploadError?: MediaDropError; // code: "upload-error", present after a failed attempt uploadResult?: unknown; // whatever the transport resolved with — opaque to the engine uploadAttempts?: number; // 1-indexed, for the current/last upload run }; ``` `uploadError.code` is always `"upload-error"`. `uploadError.status` (HTTP status) and `uploadError.sourceCode` (a transport's own finer-grained error classification, if it attaches one) are both optional. Don't switch exhaustively on `sourceCode`; it's transport-specific and open-ended. `uploadStatus` is **`undefined` until an upload is requested** for that file — a freshly-accepted file has no `uploadStatus` at all, not `"queued"`. It only ever applies to `status: "accepted"` files. ### `status` and `uploadStatus` stay separate `status` (`"idle" | "accepted" | "rejected"`) is the Core validation verdict and is **never touched by the upload queue** — it's decided once, when the file is added. `uploadStatus` is a completely independent field for the upload lifecycle: - `getAcceptedFiles()`/`getRejectedFiles()` behave identically whether or not any upload has started, finished, or failed. - `maxFiles` counting (based on `status`) is unaffected by upload progress — a file finishing its upload does not free up a `maxFiles` slot. ## How do I configure concurrency, retry, and cancel? ```ts useMediaDrop({ transport, concurrency: 3, // max uploads in flight at once. Default 1 (sequential). retries: 2, // retries *after* the first attempt, shared for every file. Default 0. retryDelays: [1000, 2000, 4000], // backoff per retry; last value repeats if exhausted. cancelGraceMs: 5000, // force-free a slot this long after cancel if the transport never settles. Default 5000. }); ``` - **`uploadFile(id)`** — queues a file (or restarts it, even if it already finished/failed/was canceled). No-op if the file isn't `status: "accepted"` or is already in flight. - **`uploadAll()`** — queues every currently `status: "accepted"` file. - **`cancelUpload(id)`** — aborts it if it's uploading (via `AbortSignal`, standard web API), or drops it if it's merely queued. Ends in `uploadStatus: "canceled"`, not `"error"`. - **`cancelAllUploads()`** — cancels every queued and in-flight file. - **`retryUpload(id)`** — re-enqueues a file, but only if its last attempt ended in `uploadStatus: "error"` — a no-op otherwise. This is a *manual* retry after automatic retries were exhausted, distinct from the automatic `retries` config above. Retrying stops immediately once a file is canceled — a cancel always wins over a pending retry. :::info **`cancelGraceMs` (default `5000`)** is a safety net, not the normal path: a well-behaved transport wires up `signal` and rejects promptly once aborted, so cancel usually settles almost immediately. If a transport doesn't, this timer force-frees the slot after the grace period regardless. ::: **`removeFile(id)`/`clearFiles()` cancel any in-flight upload for the files they remove** — no orphaned request keeps running in the background with a leaked concurrency slot. ## What's the transport contract? ```ts type UploadTransport = { upload( file: MediaDropFile, context: { onProgress: (progress: { loaded: number; total: number | null }) => void; signal: AbortSignal; }, ): Promise<{ response?: unknown }>; }; ``` See [Writing a custom transport](/guides/custom-transport) for the full guide. ## What are the session-persistence utilities for? `react-mediadrop` still exports the metadata-persistence utilities built for resumable transports, even though no transport in this codebase currently uses them: ```ts type MediaDropUploadSessionStore = { get(key: string): Promise; set(key: string, value: unknown): Promise; remove(key: string): Promise; }; createMemoryUploadSessionStore(); // in-process only — gone on reload, gone between tabs createBrowserUploadSessionStore({ prefix? }); // localStorage-backed, SSR-safe (no-op without `window`) createFileFingerprint(file: File): string; // name+size+type+lastModified+webkitRelativePath, not file contents ``` These stores hold metadata only — upload IDs, byte offsets, completed part numbers — never file bytes. `createFileFingerprint` is metadata-based on purpose: hashing file *contents* would let two selections of a huge file be compared reliably, but reading the whole file to do that is exactly the cost react-mediadrop avoids imposing by default. Two different files with identical name, size, type, modified time, and relative path will still collide — this is "looks like the same file," not a guarantee. Implement the transport contract yourself The file model, the store, and drag state --- # Validation Source: https://www.mediadrop.dev/docs/guides/validation `useMediaDrop` rejects a file the moment it's added if it fails `restrictions` or your own `validator` — both take the same shape on every call. ## What can restrictions check? ```ts type MediaDropRestrictions = { maxFiles?: number; minSize?: number; // bytes maxSize?: number; // bytes accept?: string[] | string; // mime types, wildcards, or extensions }; ``` `accept` tokens can be: - An exact mime type: `"image/png"` - A wildcard mime type: `"image/*"` - A file extension: `".png"` (matched against the file name, case-insensitive) You can pass an array or a comma-separated string (`"image/png,image/webp"` is equivalent to `["image/png", "image/webp"]`). An empty/missing `accept` accepts every file type. ## What does a validation error look like? ```ts type MediaDropErrorCode = | "file-invalid-type" | "file-too-large" | "file-too-small" | "too-many-files" | "validator-error" | "upload-error"; type MediaDropError = { code: MediaDropErrorCode; message: string; // for display — don't branch on this status?: number; // HTTP status, upload errors only sourceCode?: string; // transport-specific finer-grained code, upload errors only }; ``` Every rejected `MediaDropFile` has a non-empty `errors` array using these codes (plus whatever code your custom validator assigns). Switch on `code`, not on `message` — messages are for display, not branching logic. `status`/`sourceCode` are only ever populated on upload errors (see [Upload](/guides/upload)) — always absent on a validation error. ## How do I write a custom validator? ```ts type MediaDropValidator = ( file: File, ) => MediaDropError | MediaDropError[] | null | undefined; ``` Return `null`/`undefined` to pass. Return one error or an array of errors to reject the file — you choose the `code` (use `"validator-error"` unless you have a better fit from the built-in codes) and the `message`. ```ts function validator(file: File) { if (file.name.includes(" ")) { return { code: "validator-error", message: "Filenames can't contain spaces" }; } return null; } ``` Do not reimplement `accept`/`maxSize`/`minSize` checks inside a custom validator — use `restrictions` for those. Reserve the validator for rules `restrictions` can't express (content sniffing, naming conventions, cross-file business rules, etc.). ## Does the validator run during drag? The validator's primary job is drop-time validation, but `useMediaDrop` also runs it as part of the best-effort `isDragAccept`/`isDragReject` preview during an active drag — see [Core concepts](/guides/core-concepts#how-do-i-know-when-a-file-is-being-dragged). This only happens when the browser hands back a real `File` via `DataTransferItem.getAsFile()` before drop; when it doesn't, the preview silently falls back to accept-only evaluation. Don't rely on this for correctness — the authoritative accept/reject decision is always the one made at drop time. ## What doesn't validation do? - **No async validators.** The validator runs synchronously against the `File` object (name/size/type only) — it cannot read file contents or await a network check. - **No re-validation after the fact.** Once a file is added, its `status` and `errors` don't change unless you remove and re-add it. - **No image-specific checks** (dimensions, aspect ratio, decode-ability). If you need that, it's a custom validator today, not a first-class option. The file model, the store, and drag state The queue, concurrency, retry, and cancel --- # Contributing Source: https://www.mediadrop.dev/docs/project/contributing Thanks for your interest in contributing to `react-mediadrop`. ## Install & verify ```sh pnpm install pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm size ``` This is exactly what CI runs. ## Scope Check the [Roadmap](/roadmap) and the [scope reference](https://github.com/autorender/react-mediadrop/blob/main/skills/mediadrop/references/scope.md) before adding a feature — it's the authoritative "what's real" list. Don't build around something listed as not implemented; raise it instead. ## Architecture non-negotiables - The core engine stays framework-free with zero runtime dependencies. - Retry/backoff lives in one place (`withRetry`) and is never duplicated per transport. - Every transport stays thin — no retry, no concurrency logic of its own. See [Upload](/guides/upload) and [Writing a custom transport](/guides/custom-transport) for why this matters. ## Style Biome (`pnpm format`) formats and lints. No comments beyond what explains a non-obvious *why* (a hidden constraint, a workaround, something that would surprise a reader) — code should read clearly enough not to need a *what* comment. ## Tests Real regressions, not padding — new tests should exercise an actual race/edge case (cancel-vs-resolve races, retry exhaustion, reentrancy) the way the existing suite does, not just happy-path smoke tests. ## Opening a pull request Maintainers branch directly off latest `main`. Outside contributors fork the repo first, then branch off their fork's `main`. If the change is user-facing (bug fix, feature, behavior change), run `pnpm changeset` and commit the generated file — `changesets/action` reads these to version and changelog the next release. Skip it for docs-only or internal changes. Open the PR against `main`. If it's your first PR here, its CI run won't start until a maintainer manually approves it — standard GitHub protection for first-time contributors on public repos, a one-time step. Merging requires **1 approving review** and all **4** CI checks (`lint`, `typecheck`, `test`, `build`) passing. `main` has a linear history — merges are squash or rebase only, never merge commits. --- Issue and PR templates, a vulnerability-reporting process, and a [Code of Conduct](https://github.com/autorender/react-mediadrop/blob/main/CODE_OF_CONDUCT.md) already exist in the repo. There's no CLA yet. --- # Roadmap Source: https://www.mediadrop.dev/docs/roadmap This page tracks what's real today versus what's coming — see [Introduction](/#whats-here-today) for what's implemented today. ## Coming soon Tell us what to build next and we'll prioritize it. Open a [discussion](https://github.com/autorender/react-mediadrop/discussions) for feature requests.