Skip to main content

Guides

WIP

Copy-paste recipes for common upload scenarios.

Focused recipes built on the real API. Each assumes the shared types from Installation.

Minimal store (vanilla)

import type { Contracts } from '@gentleduck/upload/core'
import { createUploadStore } from '@gentleduck/upload/core'
import { PostStrategy, createStrategyRegistry } from '@gentleduck/upload/strategies'

type Intents = { post: PostStrategy.Intent }
type Cursors = { post?: PostStrategy.Cursor }
type Purpose = 'document'
type Result = Contracts.Result.Base & { url: string }

const api: Contracts.Api.Me<Intents, Purpose, Result> = {
  async createIntent({ purpose, contentType, size, filename }, ctx) {
    const res = await fetch('/api/uploads/intent', {
      method: 'POST',
      body: JSON.stringify({ purpose, contentType, size, filename }),
      signal: ctx.signal,
    })
    return res.json()
  },
  async complete({ fileId, filename, contentType, size }, ctx) {
    const res = await fetch(`/api/uploads/${fileId}/complete`, {
      method: 'POST',
      body: JSON.stringify({ filename, contentType, size }),
      signal: ctx.signal,
    })
    return res.json()
  },
}

const strategies = createStrategyRegistry<Intents, Cursors, Purpose, Result>()
strategies.set(PostStrategy())

export const store = createUploadStore<Intents, Cursors, Purpose, Result>({ api, strategies })

Drive it:

store.dispatch({ type: 'addFiles', files: selectedFiles, purpose: 'document' })
store.dispatch({ type: 'startAll' })
store.on('upload.completed', ({ localId, result }) => console.log(localId, result))

Without autoStart, start manually with start/startAll.

React quick start

import { UploadProvider, useUploader } from '@gentleduck/upload/react'
import { store } from './upload'

export function App() {
  return (
    <UploadProvider store={store}>
      <UploadPage />
    </UploadProvider>
  )
}

function UploadPage() {
  const { items, dispatch, on } = useUploader<Intents, Cursors, Purpose, Result>()

  React.useEffect(() => on('upload.completed', ({ localId, result }) =>
    console.log('done', localId, result)
  ), [on])

  return (
    <div>
      <input
        type="file"
        multiple
        onChange={(e) =>
          dispatch({ type: 'addFiles', files: Array.from(e.target.files ?? []), purpose: 'document' })
        }
      />
      <ul>
        {items.map((item) => (
          <li key={item.localId}>
            {item.fingerprint.name}{item.phase}
            {item.phase === 'uploading' && ` (${item.progress.pct.toFixed(0)}%)`}
          </li>
        ))}
      </ul>
    </div>
  )
}

item.progress only exists once you're in a phase that has it (uploading, completing, paused) — narrow on item.phase first.

Support large files with multipart

Register both strategies and let the backend pick per file:

import { PostStrategy, multipartStrategy, MultipartStrategy, createStrategyRegistry } from '@gentleduck/upload/strategies'

type Intents = { post: PostStrategy.Intent; multipart: MultipartStrategy.Intent }
type Cursors = { post?: PostStrategy.Cursor; multipart?: MultipartStrategy.Cursor }

const strategies = createStrategyRegistry<Intents, Cursors, Purpose, Result>()
strategies.set(PostStrategy())
strategies.set(multipartStrategy({ maxPartConcurrency: 4 }))

Your createIntent chooses a strategy by returning the matching intent — the engine dispatches on the intent's strategy field. The multipart strategy also needs api.multipart.signPart and api.multipart.completeMultipart (see Multipart Strategy).

Auto-start selected purposes

createUploadStore({
  api,
  strategies,
  config: { autoStart: ['avatar'] }, // or: autoStart: (p) => p !== 'draft'
})

Matching files upload as soon as their intent is created — no manual start.

Enable resume across reloads

import { IndexedDBAdapter } from '@gentleduck/upload/core'

const store = createUploadStore<Intents, Cursors, Purpose, Result>({
  api,
  strategies,
  persistence: {
    key: 'uploads',
    version: 1,
    adapter: IndexedDBAdapter,
    isPurpose: (v): v is Purpose => v === 'avatar' || v === 'document',
    isIntent: (v): v is Intents[keyof Intents] =>
      typeof v === 'object' && v !== null && 'strategy' in v && 'fileId' in v,
  },
})

Restored items come back paused without a File. Rebind before resuming:

store.dispatch({ type: 'rebind', localId, file: reselectedFile })
store.dispatch({ type: 'resume', localId })

See Persistence for the full flow.

Await a batch of uploads

const ids = Array.from(store.getSnapshot().items.keys())
for (const outcome of await store.waitFor(ids)) {
  if (outcome.status === 'completed') console.log(outcome.result.key)
  else if (outcome.status === 'error') console.warn(outcome.error.code)
}