Skip to main content
Solid Primitives 2

Primitives for uploading files.

StageCategoryVersionLast UpdatedDemo
3Browser APIs1.0.0-next.3 (next)Aug 17, 2026Demo →
Terminal window
npm i @solid-primitives/upload@next

Primitives for file picking, drag-and-drop zones, and XHR uploads with progress tracking.

  • createFilePicker — opens the OS file-picker and exposes selected files reactively
  • createFileUploader — uploads files with reactive progress, status, and error; transport is passed in explicitly
  • fileSender — XHR transport factory for createFileUploader (tree-shakeable)
  • fileUploader — ref callback factory for <input type="file"> elements
  • createDropzone — reactive drag-and-drop zone with full drag-event callbacks
  • dropzone — ref callback factory variant of createDropzone

createFilePicker

Opens the OS file-picker dialog when called and exposes the selected files, loading state, and any callback error as reactive signals.

import { createFilePicker } from "@solid-primitives/upload";
// Single file
const { files, isLoading, error, selectFiles } = createFilePicker();
// Multiple files with MIME filter
const { files, isLoading, error, selectFiles } = createFilePicker({
multiple: true,
accept: "image/*",
});
// Open the picker and do something with the selection
selectFiles(async files => {
await uploadToServer(files);
});

Returned object:

NameTypeDescription
filesAccessor<UploadFile[]>Reactive list of selected files; updated on every selection
errorAccessor<unknown>Error thrown by the last selectFiles callback; null if none
isLoadingAccessor<boolean>true while the selectFiles callback is pending
selectFiles(callback?: UserCallback) => voidOpens the file-picker and runs the optional callback on change
removeFile(fileName: string) => voidRemoves a single file from the list by name
clearFiles() => voidClears all selected files

Note: removeFile matches by file name. If the list contains duplicate file names, only the first match is removed.

Options:

OptionTypeDefaultDescription
acceptstring""Comma-separated list of accepted file types (passed to <input accept>). E.g. "image/*", ".pdf,.doc"
multiplebooleanfalseAllow selecting more than one file at once

Usage example — combined picker + uploader:

import { createFilePicker, createFileUploader, fileSender } from "@solid-primitives/upload";
const { selectFiles } = createFilePicker({ multiple: true, accept: "image/*" });
const { upload, files, progress, status } = createFileUploader(fileSender("/api/upload"));
<button onClick={() => selectFiles(fs => upload(fs))} disabled={status() === "uploading"}>
Select & upload
</button>
<Show when={status() === "uploading"}>
<progress value={progress().percentage} max={100} />
<span>{progress().percentage}%</span>
</Show>
<For each={files}>{f =>
<div>
{f.file.name}
<Show when={f.status === "error"}>
<span> — failed: {String(f.error)}</span>
</Show>
</div>
}</For>

createFileUploader

Uploads files with reactive per-file and aggregate progress, status, and error tracking. Each file in a batch gets its own parallel request. The transport is passed in explicitly — use the bundled fileSender factory for XHR, or supply your own. Keeping them separate lets bundlers tree-shake fileSender when it is not needed.

import { createFileUploader, fileSender } from "@solid-primitives/upload";
const { upload, files, progress, status, abort } = createFileUploader(fileSender("/api/upload"));
// upload() dispatches one request per file (in parallel); resolves when all settle
const results = await upload(myFiles);

Returned object:

NameTypeDescription
upload(files: UploadFile[]) => Promise<unknown[]>Send files in parallel; resolves when all settle
filesreadonly FileUploadEntry[]Store array — per-file progress, status, error, and response
progressAccessor<UploadProgress>Aggregate { loaded, total, percentage } across all files
statusAccessor<UploadStatus>Aggregate status: uploading > error > aborted > success > idle
abort() => voidCancel all in-flight uploads
removeFile(fileName: string) => voidRemove one entry by name; aborts all in-flight uploads
clearFiles() => voidRemove all entries; aborts all in-flight uploads

Note: removeFile matches by file.name. If a batch contains duplicate file names, only the first match is removed. Avoid batching files with duplicate names when using removeFile.

Each entry in files has the shape:

type FileUploadEntry = {
file: UploadFile;
progress: UploadProgress; // { loaded, total, percentage }
status: UploadStatus; // "idle" | "uploading" | "success" | "error" | "aborted"
error: unknown; // error from a failed upload; null otherwise
response: unknown; // parsed server response on success; null otherwise
};

Read files[i] directly in JSX for fine-grained per-file reactivity — only the row that changed re-renders:

<For each={files}>
{f => (
<div>
{f.file.name} — {f.progress.percentage}%
<Show when={f.status === "error"}>
<span>Error: {String(f.error)}</span>
</Show>
</div>
)}
</For>

Calling upload again while one is in-flight cancels the previous upload and resets all per-file state. Use abort() to cancel without starting a new one.

fileSender

Factory that creates a SendFunction backed by XHR. Imported separately so it can be tree-shaken when unused.

import { fileSender } from "@solid-primitives/upload";
// Basic
createFileUploader(fileSender("/api/upload"));
// With options
createFileUploader(
fileSender("/api/upload", { fieldName: "attachment", headers: { "X-Auth": token } }),
);

Options:

OptionTypeDefaultDescription
fieldNamestring"file"FormData field name used for each file
headersRecord<string, string>{}Additional request headers (do not set Content-Type; the browser sets it for FormData)

Custom SendFunction:

Provide your own transport — fetch, WebSocket, a test double, etc. The function is called once per file and receives the file, a progress callback, and an AbortSignal. It must return a Promise resolving with the server response or rejecting on failure. Reject with new DOMException("...", "AbortError") when the signal fires so that file's status transitions to "aborted".

const { upload, progress, status } = createFileUploader(async (file, onProgress, signal) => {
const body = new FormData();
body.append("file", file.file, file.name);
const res = await fetch("/api/upload", { method: "POST", body, signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});

fileUploader

A ref callback factory for wiring an existing <input type="file"> element into your own reactive state. Use this when you want full control over the input element's markup (e.g. for custom styling).

import { fileUploader } from "@solid-primitives/upload";
import { createSignal } from "solid-js";
import type { UploadFile } from "@solid-primitives/upload";
const [files, setFiles] = createSignal<UploadFile[]>([]);
const [uploadError, setUploadError] = createSignal<unknown>(null);
<input
type="file"
multiple
accept="image/*"
ref={fileUploader({
userCallback: async fs => {
await uploadToServer(fs);
},
setFiles,
onError: err => setUploadError(err),
})}
/>;

If onError is omitted, a rejection from userCallback propagates as an unhandled promise rejection.

Options:

OptionTypeDescription
userCallbackUserCallbackCalled with the parsed files on every change event
setFilesSetter<UploadFile[]>Receives the parsed UploadFile[] on every change
onError(error: unknown) => voidCalled when userCallback throws; defaults to rethrowing

createDropzone

A reactive drag-and-drop zone. Attach it to any element via the ref callback and respond to drag lifecycle events. Implemented on top of createNativeDroppable from @solid-primitives/drag-drop — the depth-counter logic, isOver tracking, and element cleanup are all handled by the underlying primitive.

import { createDropzone, createFileUploader, fileSender } from "@solid-primitives/upload";
const { upload, progress, status } = createFileUploader(fileSender("/api/upload"));
const { ref, files, isDragging, error } = createDropzone({
onDrop: files => upload(files),
});
<div
ref={ref}
style={{
background: isDragging() ? "lightblue" : "lightgray",
padding: "2rem",
border: "2px dashed #999",
}}
>
<Show when={status() === "uploading"} fallback="Drop files here">
Uploading… {progress().percentage}%
</Show>
<Show when={error()}>
<p>Error: {String(error())}</p>
</Show>
<For each={files()}>{file => <p>{file.name}</p>}</For>
</div>;

Returned object:

NameTypeDescription
ref(el: T) => voidRef callback — pass to the ref prop of the drop target element
filesAccessor<UploadFile[]>Reactive list of the most recently dropped files
errorAccessor<unknown>Error thrown by the last onDrop callback; null if none
isLoadingAccessor<boolean>true while the onDrop callback is pending
isDraggingAccessor<boolean>true while a drag is active over the element
removeFile(fileName: string) => voidRemoves a single file from the list by name
clearFiles() => voidClears all dropped files

Note: removeFile matches by file name. If the list contains duplicate file names, only the first match is removed.

Options (all optional):

CallbackSignatureFires when…
onDrop(files: UploadFile[]) => void | Promise<void>Files are dropped; isLoading is true while it awaits
onDragEnter(event: DragEvent) => void | Promise<void>A dragged item enters the element
onDragLeave(event: DragEvent) => void | Promise<void>A dragged item leaves the element
onDragOver(event: DragEvent) => void | Promise<void>An item is dragged continuously over the element

Only onDrop receives UploadFile[] — the browser only exposes real File objects on dataTransfer.files at drop time, so onDragEnter/onDragLeave/onDragOver receive the raw DragEvent instead (inspect event.dataTransfer.items / .types for metadata about what's being dragged). isLoading tracks only the onDrop callback — drag-movement callbacks are fire-and-forget.

dropzone

A ref callback factory variant of createDropzone. Returns a single value that is both the ref callback and the reactive state object — use it directly as a ref while reading .files, .isDragging, etc. from the same reference. Mirrors the fileUploader pattern.

import { dropzone, createFileUploader, fileSender } from "@solid-primitives/upload";
const { upload, progress, status } = createFileUploader(fileSender("/api/upload"));
const dz = dropzone({ onDrop: files => upload(files) });
<div
ref={dz}
style={{
background: dz.isDragging() ? "lightblue" : "lightgray",
padding: "2rem",
border: "2px dashed #999",
}}
>
<Show when={status() === "uploading"} fallback="Drop files here">
Uploading… {progress().percentage}%
</Show>
<For each={dz.files()}>{file => <p>{file.name}</p>}</For>
</div>;

The returned value is a function (the ref callback) with all createDropzone state properties attached directly to it — files, error, isLoading, isDragging, removeFile, and clearFiles. Accepts the same DropzoneOptions as createDropzone.

Types

type UploadFile = {
source: string; // blob URL from URL.createObjectURL
name: string;
size: number;
file: File;
};
type UploadStatus = "idle" | "uploading" | "success" | "error" | "aborted";
type UploadProgress = {
loaded: number;
total: number;
percentage: number; // 0–100
};
type SendFunction = (
file: UploadFile,
onProgress: (progress: UploadProgress) => void,
signal: AbortSignal,
) => Promise<unknown>;
type FileUploadEntry = {
file: UploadFile;
progress: UploadProgress;
status: UploadStatus;
error: unknown;
response: unknown;
};
type UserCallback = (files: UploadFile[]) => void | Promise<void>;
// Drag-movement callbacks (onDragEnter/onDragLeave/onDragOver) receive the raw
// DragEvent instead of UploadFile[] — dataTransfer.files is only populated at drop.
type DragEventCallback = (event: DragEvent) => void | Promise<void>;
type FilePickerOptions = {
accept?: string;
multiple?: boolean;
};
type FileSenderOptions = {
fieldName?: string;
headers?: Record<string, string>;
};
type FileUploaderDirective = {
userCallback: UserCallback;
setFiles: Setter<UploadFile[]>;
onError?: (error: unknown) => void;
};

SSR

All primitives are SSR-safe. On the server they return no-op stubs so components render without errors.

Changelog

See CHANGELOG.md

Solid Primitives 2High-quality reactive primitives for building applications in Solid2
Community
githubdiscord