Installation
WIPGet started with @gentleduck/upload in your project.
Requirements
- A modern browser with
XMLHttpRequest(the default transport uses it for byte-level upload progress). - For the React binding: React 19.
Install
@gentleduck/upload
Or with a specific manager:
bun add @gentleduck/upload
npm install @gentleduck/upload
npm install @gentleduck/upload
pnpm add @gentleduck/upload
One package ships everything. The core engine, React bindings, and built-in strategies are reachable through three subpath entry points.
Entry points
| Import | Contains |
|---|---|
@gentleduck/upload | Re-exports all three below |
@gentleduck/upload/core | createUploadStore, Contracts, Engine, error classes, persistence adapters, createXHRTransport |
@gentleduck/upload/strategies | PostStrategy, multipartStrategy, createStrategyRegistry |
@gentleduck/upload/react | UploadProvider, useUploader, useUploaderActions |
Four steps to a working store
1. Describe your pipeline
The store is generic over <M, C, P, R> (intent map, cursor map, purpose, result). Define
them once and reuse everywhere:
import type { Contracts } from '@gentleduck/upload/core'
import type { PostStrategy } from '@gentleduck/upload/strategies'
type Intents = { post: PostStrategy.Intent }
type Cursors = { post?: PostStrategy.Cursor }
type Purpose = 'avatar' | 'document'
type Result = Contracts.Result.Base & { url: string }
Contracts.Result.Base is { fileId: string; key: string } — extend it with whatever your
backend returns.
2. Implement the backend contract
Your adapter implements Contracts.Api.Me<M, P, R>. Only createIntent and complete are
required. Note the second argument is the call context (ctx), which carries the abort
signal, the File, the fingerprint, and retry metadata.
const api: Contracts.Api.Me<Intents, Purpose, Result> = {
async createIntent({ purpose, contentType, size, filename }, ctx) {
const res = await fetch('/api/uploads/intent', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ purpose, contentType, size, filename }),
signal: ctx.signal,
})
if (!res.ok) throw new Error(`intent failed: ${res.status}`)
// Returns a value from the intent map — here a PostStrategy.Intent.
return res.json()
},
async complete({ fileId, filename, contentType, size }, ctx) {
const res = await fetch(`/api/uploads/${fileId}/complete`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ filename, contentType, size }),
signal: ctx.signal,
})
if (!res.ok) throw new Error(`complete failed: ${res.status}`)
return res.json()
},
}
3. Register strategies
createStrategyRegistry() returns an empty typed registry. Add strategies with .set():
import { PostStrategy, createStrategyRegistry } from '@gentleduck/upload/strategies'
const strategies = createStrategyRegistry<Intents, Cursors, Purpose, Result>()
strategies.set(PostStrategy())
4. Create the store
import { createUploadStore } from '@gentleduck/upload/core'
export const store = createUploadStore<Intents, Cursors, Purpose, Result>({
api,
strategies,
config: {
maxConcurrentUploads: 3,
autoStart: ['avatar'],
},
})
If you omit transport, the store installs createXHRTransport() for you.
Add React (optional)
Wrap your tree in UploadProvider and read the store with useUploader:
import { UploadProvider, useUploader } from '@gentleduck/upload/react'
import { store } from './upload'
export function App() {
return (
<UploadProvider store={store}>
<UploadList />
</UploadProvider>
)
}
function UploadList() {
const { items, dispatch } = useUploader<Intents, Cursors, Purpose, Result>()
return (
<div>
<input
type="file"
multiple
onChange={(e) =>
dispatch({ type: 'addFiles', files: Array.from(e.target.files ?? []), purpose: 'document' })
}
/>
{items.map((item) => (
<div key={item.localId}>
{item.fingerprint.name} — {item.phase}
</div>
))}
</div>
)
}
Next
- Design Decisions — why the engine is shaped this way.
- Core Overview — the modules behind the store.
- Guides — copy-paste recipes for common scenarios.