Chapter 5: Pause, Resume & Retry
WIPControl the upload lifecycle — pause, resume, cancel — and configure automatic retry with backoff.
Goal
PhotoDuck users upload big photos on flaky connections. Add pause / resume / cancel controls and an automatic retry policy with exponential backoff.
The hook returns commands, not the store
useUploader returns items, the phase buckets, and dispatch/on/off. It does not
return the store object. When you need the raw store (for getSnapshot or waitFor), use
useUploaderActions, which returns { dispatch, on, store }.
import { useUploader, useUploaderActions } from '@gentleduck/upload/react'
const { items, dispatch } = useUploader<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
const { store } = useUploaderActions<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
Lifecycle commands
Pause
dispatch({ type: 'pause', localId })
On pause the engine sets the in-flight upload's mode to 'pause' and aborts its
AbortController. The strategy catches the abort, its cursor is already persisted, and the
item transitions uploading → paused. Pausing a queued item just reverts it to ready
(no network was in flight).
Resume
dispatch({ type: 'resume', localId })
paused → queued; the scheduler restarts the strategy, which reads the cursor and
continues from the last checkpoint. Resume requires a file. After a reload the File is
gone, so resume is a no-op until you rebind (Chapter 6).
Cancel and remove
dispatch({ type: 'cancel', localId }) // aborts + moves to canceled (final)
dispatch({ type: 'remove', localId }) // drops the item from state
Cancel aborts any in-flight request with mode 'cancel' and is terminal. remove deletes
the item entirely — handy for clearing terminal rows from the UI.
Automatic retry policy
export const store = createUploadStore<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>({
api,
strategies,
config: {
maxAttempts: 5,
retryPolicy: ({ phase, attempt, error }) => {
if (error.code === 'auth') return { retryable: false }
if (error.code === 'validation_failed') return { retryable: false }
const delayMs = Math.min(1000 * 2 ** (attempt - 1), 30_000) // 1s,2s,4s,8s,16s
return { retryable: true, delayMs }
},
},
})
retryPolicy runs whenever a phase fails. It receives phase ('intent' | 'upload' | 'complete'), the attempt number, and the UploadError, and returns an
Engine.RetryDecision. maxAttempts is the hard cap even when the policy says retryable.
Retry UI
import type { Engine } from '@gentleduck/upload/core'
type Item = Engine.Item<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>
function UploadRow({ item, dispatch }: {
item: Item
dispatch: (cmd: Engine.Command<PhotoPurpose>) => void
}) {
if (item.phase === 'error') {
return (
<div>
<span style={{ color: '#dc2626' }}>{item.error.message}</span>
{item.retryable && (
<button onClick={() => dispatch({ type: 'retry', localId: item.localId })}>
Retry (attempt {item.attempt})
</button>
)}
<button onClick={() => dispatch({ type: 'remove', localId: item.localId })}>Dismiss</button>
</div>
)
}
return (
<div>
{item.phase === 'uploading' && (
<>
<progress value={item.progress.pct} max={100} />
<button onClick={() => dispatch({ type: 'pause', localId: item.localId })}>Pause</button>
</>
)}
{item.phase === 'paused' && (
<button onClick={() => dispatch({ type: 'resume', localId: item.localId })}>Resume</button>
)}
<button onClick={() => dispatch({ type: 'cancel', localId: item.localId })}>Cancel</button>
</div>
)
}
retry is only accepted from the error phase. Where it re-enters depends on how far the
item got:
- no intent yet →
creating_intent - has intent, bytes pending →
ready(re-queued) - failed while finalizing →
completing(retries only the finalize call)
Batch operations
startAll, pauseAll, and cancelAll apply to every matching item; pass a purpose to scope:
dispatch({ type: 'startAll' })
dispatch({ type: 'pauseAll' })
dispatch({ type: 'cancelAll' })
dispatch({ type: 'startAll', purpose: 'photo' }) // only photos
| Command | Valid from |
|---|---|
start / startAll | ready |
pause / pauseAll | uploading, queued |
resume | paused (needs file) |
cancel / cancelAll | any non-terminal |
retry | error (needs retryable) |
remove | any |
rebind | paused without file |
Full transition map
Checkpoint
A full controls component. Read items from useUploader; grab store from useUploaderActions
only if you need getSnapshot/waitFor.
import { useUploader } from '@gentleduck/upload/react'
export function PhotoUploader() {
const { items, dispatch } = useUploader<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
const active = items.filter((i) => i.phase === 'uploading' || i.phase === 'queued').length
return (
<div>
<input
type="file"
multiple
accept="image/*"
onChange={(e) => {
const files = Array.from(e.target.files ?? [])
if (files.length) dispatch({ type: 'addFiles', files, purpose: 'photo' })
}}
/>
<div>
<button onClick={() => dispatch({ type: 'startAll' })}>Start all</button>
<button onClick={() => dispatch({ type: 'pauseAll' })}>Pause all</button>
<button onClick={() => dispatch({ type: 'cancelAll' })}>Cancel all</button>
<span>{active} active</span>
</div>
<ul>
{items.map((item) => (
<li key={item.localId}>
{item.fingerprint.name} — {item.phase}
{'progress' in item && item.progress && ` (${Math.round(item.progress.pct)}%)`}
{item.phase === 'uploading' && (
<button onClick={() => dispatch({ type: 'pause', localId: item.localId })}>Pause</button>
)}
{item.phase === 'paused' && (
<button onClick={() => dispatch({ type: 'resume', localId: item.localId })}>Resume</button>
)}
{item.phase === 'error' && item.retryable && (
<button onClick={() => dispatch({ type: 'retry', localId: item.localId })}>Retry</button>
)}
</li>
))}
</ul>
</div>
)
}