Uploader

A controller that pairs with <Attachment> to handle picking, validating, dragging, dropping, pasting, and uploading. Renders no chrome of its own.

Anatomy

The root owns the queue, the hidden file input, the abort wiring, and the blob URL lifecycle for image previews. Triggers, dropzones, and lists live anywhere underneath and read the queue through useUploader(). You bring the trigger, the dropzone, the rows, and the image element.

TSX

The kit does not ship its own image part. Every item carries a resolved preview string (previewUrl if the server set one, otherwise a blob URL the kit manages and revokes on remove). Drop in a plain <img>, a framework image (Next/Astro), or any project-owned image component. Tag it data-slot="attachment-media-img" so the surrounding AttachmentMedia sizes it.

The dropzone is optional. Drop it in for a drag target, leave it out for a button-only flow. The dropzone exposes data-drag-over while a file drag is over the element, so the consumer can tone the surface through a Tailwind variant.

TSX

For an avatar tile, skip the trigger and bind open from useUploader() directly to a clickable <Attachment>. Use maxFiles={1} to make the queue replace-on-add.

TSX

To attach drop and paste behavior to an arbitrary surface without making it look like a dropzone, use useUploaderAttach on a ref. Useful for a composer or a canvas that should accept files as a side behavior.

TSX

The uploader function

You write it. The kit calls it once per item.

TS

The id lets you key external state by item, which matters for retries and resumable flows. The signal aborts when the user cancels or removes the item; wire it into your transport. The progress callback takes a number in [0, 100]. The resolved value lands on item.result. If it has a string url field, the kit copies it into item.previewUrl so reloaded items can show a thumbnail without holding the original File.

Writing a custom uploader

The bundled createXhrUploader is a shortcut for the most common case (one multipart POST per file, with progress). Anything beyond that is a normal async function. The four arguments above are the only contract; what happens in between is yours.

The smallest useful uploader is a fetch. No progress, because fetch cannot report upload progress, but the kit still drives the row from pending to success and surfaces the resolved body on item.result.

TSX

For progress, fall back to XMLHttpRequest and forward xhr.upload.onprogress to onProgress. Wire signal.addEventListener("abort", () => xhr.abort()) so cancel(id) actually stops the request. The pattern below also covers the common signed URL handshake: a JSON call to your API to mint a presigned URL, then a direct PUT to storage.

TSX

Throw an Error with a code property to land a typed error on item.error. The kit synthesizes upload_failed when no code is provided. Return { url } (or anything with a string url field) to set the durable previewUrl on success. For chunked or resumable transports, see Resumable uploads.

Swapping preview on success

The kit swaps from the blob URL to your server's URL automatically. Two pieces wire it up.

When your uploader resolves with an object that has a string url field, that value lands on item.previewUrl:

TSX

And item.preview is derived, not stored. Every render computes preview = previewUrl ?? blobUrl. While the upload is in flight, previewUrl is undefined so preview is the blob URL. The moment previewUrl is set, the next render switches preview to the remote URL. Your <img src={item.preview} /> re-renders with the new source. No manual swap, no callback.

The blob URL itself stays alive until the item leaves the queue (then the kit revokes it), so the cutover never produces a flash of empty media.

If your server does not return a { url } shape, set previewUrl yourself from onSuccess in controlled mode.

TSX

Same preview = previewUrl ?? blobUrl rule applies, so the swap follows automatically.

Item shape

TS

file is the volatile half. It exists in the session the file was picked in and is gone after a reload. Everything else is durable. To restore a previous queue, hydrate items with the durable fields, status: "success", and progress: 100. The kit does not auto-start items that have no file.

previewUrl is what the consumer or server owns. preview is the rendered source the kit derives from it: equal to previewUrl when set, otherwise a managed blob URL for an image-typed File. Render preview directly; don't try to construct your own blob URL on top of file.

Persistence

The motivating case is a long form with several attachments. The user uploads three files, accidentally reloads, and the three rows come back with their thumbnails intact. The kit ships no storage adapter, but the wiring is small.

Two rules cover the common cases. First, only persist success items. Anything in flight at the time of reload has no File to resume against and is not meaningful to restore. Second, strip the file field before serialization, since File does not survive JSON.stringify.

TSX

onItemsChange is the right channel for this. It fires on structural changes (add, status transition, remove) but not on progress ticks, so a localStorage write inside the handler does not run on every percentage update.

For the thumbnail to survive reload, the durable source needs to live in previewUrl. A server response with { url } flows there automatically (see Swapping preview on success). For a fully local demo with no backend, a FileReader data URL works in place of a remote URL.

Resumable uploads

The kit does not ship a resumable transport, but the surface gives you the seams to build one: a custom uploader function that owns the protocol, controlled items with onItemsChange for persistence, and retry(id) to restart. The id passed into your uploader is the key for an external session store.

The shape below uses a tus-style request flow. Swap in S3 multipart or your own scheme without touching the kit.

TSX

On rehydration, mark any item that has a session but is not in "success" as "error" with a code that distinguishes it from a real failure. Show a Resume button on those rows. When the user picks the file again, splice it into the existing item and call retry, so the item id (and the session keyed off it) survives.

TSX

Things to keep in mind: a wrong file on resume should be refused or treated as a fresh upload, sessions can outlive items so the server-side store needs an expiry, and cancel mid-upload should keep the session row around so the user can resume later instead of restarting from zero.

Future kit additions (meta on UploadItem, previous on the uploader input, onMeta for mid-flight writes, attachFile(id, file) as a one-call swap-and-retry) will shorten this. Code written against today's surface keeps working when they land.

API

Reference for each part of the component, including its available props and behavior.

Uploader

The root. Owns the queue, the abort controllers, and the hidden file input. Renders no DOM of its own beyond the input, so layout decisions stay with the consumer.

The kit treats onItemsChange as the structural channel and onProgress as the granular one. Wiring onItemsChange to a storage write is safe; it only fires when an item is added, removed, retried, cleared, or transitions status. Progress ticks come through onProgress and the merged items array on the context.

Controlled mode follows the standard React shape: pass items and onItemsChange, then forward the new value back into state from the handler. Uncontrolled mode uses defaultItems for the initial snapshot and emits the same onItemsChange for storage.

Prop

UploaderTrigger

A passthrough button that opens the file picker. Pass the visible element through render. The kit attaches an onClick and merges with any handler on the rendered element, so a Button or a MenuItem keeps its own behavior.

TSX
Prop

UploaderDropzone

A drop target. Sets data-drag-over while a file drag is over the element, so the consumer can tone the surface through a Tailwind variant. Ignores drags that do not carry files and counts drag depth, so nested children with their own dragenter and dragleave handlers do not flicker the over state.

Prop

UploaderList

A render-prop wrapper that maps over the queue and gives you per-item actions. The item passed in already has live progress merged. The actions are bound to that item, so the call sites stay terse: onClick={cancel}, not onClick={() => cancel(item.id)}.

TSX
Prop

useUploader

Read the queue and act on it from any descendant. Returns the same shape regardless of controlled or uncontrolled mode.

TS
  • items — the live queue with per-tick progress merged in, so a render-time read of item.progress is always fresh.
  • open() — opens the hidden file picker, the same action the trigger fires.
  • add(files) — accepts a File[] or FileList and pushes them through the same validation the picker uses, applying accept, maxSize, and maxFiles.
  • cancel(id) — aborts the upload for that item and marks it canceled. No-op for items that are not in flight.
  • retry(id) — restarts a canceled or error item from scratch. The same id flows into the uploader function, which is how a resumable transport keys its session store.
  • remove(id) — drops the item entirely, aborting it first if it is still in flight.
  • clear() — empties the queue and aborts everything mid-flight.

useUploaderAttach

Attach drop and paste behavior to an arbitrary element. Useful when an existing component (a composer, a prompt, a canvas) should accept files as a side behavior without becoming a dropzone visually.

TS

The hook installs listeners on the element behind the ref, ignores drags that do not carry files, and counts drag depth so nested children with their own drag handlers do not flicker the over state.

  • isDragOver — true while a file drag is over the element. Use it to render an overlay or tone the surface.
  • options.drop — when false, the hook skips the drag listeners. Defaults to true.
  • options.paste — when false, the hook skips the paste listener. Defaults to true.

The paste listener fires on the element behind the ref, so paste only works when a focusable child (an <input>, <textarea>, or contenteditable) receives the paste event and it bubbles to the attached element. A bare <div> will not catch a global Cmd+V.

type UploadItem

The shape of a row in the queue. Stored in items, passed to onSuccess and onError, and forwarded as the first argument to the UploaderList render-prop. See Item shape for the persistence implications of each field.

Prop

type UploaderFn

The signature you pass to Uploader's uploader prop. See The uploader function for the contract and how the resolved value flows back into the item.

Prop

createXhrUploader

A factory that returns an UploaderFn for the common case of one HTTP request per file with a multipart body. Wires upload progress via xhr.upload.onprogress, aborts on signal, and parses a JSON response (falling back to { body: text } for non-JSON). The resolved value is returned verbatim, so a server that responds with { url: "..." } lights up item.previewUrl automatically.

TS
Prop

For anything beyond a single multipart POST (signed URLs, chunked transfer, your own protocol), write the UploaderFn yourself.