chapter 3: react integration
Build a drag-and-drop upload UI with React hooks.
Goal
By the end of this chapter you will have a React upload component with a file dropzone,
progress bars, and completion/error states -- all powered by the UploadProvider and
useUploader hook.
Step by Step
Wrap your app with UploadProvider
The UploadProvider makes the upload store available to all child components via React
Context. Pass the client you created in previous chapters:
import { UploadProvider } from '@gentleduck/upload'
import { uploadClient } from './upload'
import { PhotoUploader } from './PhotoUploader'
export function App() {
return (
<UploadProvider store={uploadClient}>
<div className="app">
<h1>PhotoDuck</h1>
<PhotoUploader />
</div>
</UploadProvider>
)
}import { UploadProvider } from '@gentleduck/upload'
import { uploadClient } from './upload'
import { PhotoUploader } from './PhotoUploader'
export function App() {
return (
<UploadProvider store={uploadClient}>
<div className="app">
<h1>PhotoDuck</h1>
<PhotoUploader />
</div>
</UploadProvider>
)
}UploadProvider accepts a store prop which is your UploadClient (or UploadStore).
Any component inside the provider can access it via hooks.
Use the useUploader hook
The useUploader hook subscribes to the store and returns reactive state plus actions:
import { useUploader } from '@gentleduck/upload'
export function PhotoUploader() {
const { items, dispatch, uploading, completed, failed, ready } = useUploader()
return (
<div>
<p>{items.length} files total</p>
<p>{uploading.length} uploading, {completed.length} done, {failed.length} failed</p>
</div>
)
}import { useUploader } from '@gentleduck/upload'
export function PhotoUploader() {
const { items, dispatch, uploading, completed, failed, ready } = useUploader()
return (
<div>
<p>{items.length} files total</p>
<p>{uploading.length} uploading, {completed.length} done, {failed.length} failed</p>
</div>
)
}The useUploader hook returns:
| Field | Type | Description |
|---|---|---|
items | UploadItem[] | All upload items as an array |
byPhase | Record<string, UploadItem[]> | Items grouped by phase |
dispatch | (cmd) => void | Dispatch commands (same as uploadClient.dispatch) |
on | (event, cb) => unsub | Subscribe to events |
off | (event, cb) => unsub | Unsubscribe from events |
uploading | UploadItem[] | Items in uploading phase |
paused | UploadItem[] | Items in paused phase |
completed | UploadItem[] | Items in completed phase |
failed | UploadItem[] | Items in error phase |
ready | UploadItem[] | Items in ready phase |
Build a file input / dropzone component
Add a file input and a drag-and-drop zone:
import { useUploader } from '@gentleduck/upload'
import { useCallback, useRef, useState } from 'react'
export function PhotoUploader() {
const { items, dispatch, uploading, completed, failed } = useUploader()
const inputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const addFiles = useCallback((files: File[]) => {
if (files.length > 0) {
dispatch({ type: 'addFiles', files, purpose: 'photo' })
}
}, [dispatch])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
addFiles(files)
}, [addFiles])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback(() => {
setIsDragging(false)
}, [])
return (
<div>
{/* Dropzone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${isDragging ? '#3b82f6' : '#d1d5db'}`,
borderRadius: 8,
padding: 40,
textAlign: 'center',
cursor: 'pointer',
backgroundColor: isDragging ? '#eff6ff' : 'transparent',
transition: 'all 0.2s',
}}
>
<p>{isDragging ? 'Drop files here' : 'Click or drag files to upload'}</p>
</div>
{/* Hidden file input */}
<input
ref={inputRef}
type="file"
multiple
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const files = Array.from(e.target.files ?? [])
addFiles(files)
e.target.value = ''
}}
/>
{/* Upload list */}
<div style={{ marginTop: 20 }}>
{items.map((item) => (
<UploadItemRow key={item.localId} item={item} dispatch={dispatch} />
))}
</div>
</div>
)
}import { useUploader } from '@gentleduck/upload'
import { useCallback, useRef, useState } from 'react'
export function PhotoUploader() {
const { items, dispatch, uploading, completed, failed } = useUploader()
const inputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const addFiles = useCallback((files: File[]) => {
if (files.length > 0) {
dispatch({ type: 'addFiles', files, purpose: 'photo' })
}
}, [dispatch])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const files = Array.from(e.dataTransfer.files)
addFiles(files)
}, [addFiles])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback(() => {
setIsDragging(false)
}, [])
return (
<div>
{/* Dropzone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${isDragging ? '#3b82f6' : '#d1d5db'}`,
borderRadius: 8,
padding: 40,
textAlign: 'center',
cursor: 'pointer',
backgroundColor: isDragging ? '#eff6ff' : 'transparent',
transition: 'all 0.2s',
}}
>
<p>{isDragging ? 'Drop files here' : 'Click or drag files to upload'}</p>
</div>
{/* Hidden file input */}
<input
ref={inputRef}
type="file"
multiple
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const files = Array.from(e.target.files ?? [])
addFiles(files)
e.target.value = ''
}}
/>
{/* Upload list */}
<div style={{ marginTop: 20 }}>
{items.map((item) => (
<UploadItemRow key={item.localId} item={item} dispatch={dispatch} />
))}
</div>
</div>
)
}Show upload progress with progress bars
Create a row component that displays progress, completion, and error states:
import type { UploadItem, UploadCommand } from '@gentleduck/upload'
function UploadItemRow({
item,
dispatch,
}: {
item: UploadItem<any, any, any, any>
dispatch: (cmd: UploadCommand<string>) => void
}) {
const filename = item.file?.name ?? 'Unknown file'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0' }}>
{/* Filename */}
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{filename}
</span>
{/* Phase-specific UI */}
{item.phase === 'validating' && <span>Validating...</span>}
{item.phase === 'creating_intent' && <span>Preparing...</span>}
{item.phase === 'ready' && (
<button onClick={() => dispatch({ type: 'start', localId: item.localId })}>
Start
</button>
)}
{item.phase === 'queued' && <span>Queued...</span>}
{item.phase === 'uploading' && (
<>
<div style={{ width: 120, height: 8, backgroundColor: '#e5e7eb', borderRadius: 4 }}>
<div
style={{
width: `${item.progress.pct}%`,
height: '100%',
backgroundColor: '#3b82f6',
borderRadius: 4,
transition: 'width 0.2s',
}}
/>
</div>
<span style={{ width: 50, textAlign: 'right' }}>{item.progress.pct.toFixed(0)}%</span>
<button onClick={() => dispatch({ type: 'pause', localId: item.localId })}>
Pause
</button>
</>
)}
{item.phase === 'paused' && (
<>
<span>Paused ({item.progress.pct.toFixed(0)}%)</span>
<button onClick={() => dispatch({ type: 'resume', localId: item.localId })}>
Resume
</button>
</>
)}
{item.phase === 'completing' && <span>Finalizing...</span>}
{item.phase === 'completed' && (
<span style={{ color: '#16a34a' }}>Done</span>
)}
{item.phase === 'error' && (
<>
<span style={{ color: '#dc2626' }}>{item.error.message}</span>
{item.retryable && (
<button onClick={() => dispatch({ type: 'retry', localId: item.localId })}>
Retry
</button>
)}
</>
)}
{/* Cancel button (for active uploads) */}
{!['completed', 'canceled', 'error'].includes(item.phase) && (
<button onClick={() => dispatch({ type: 'cancel', localId: item.localId })}>
Cancel
</button>
)}
{/* Remove button (for terminal states) */}
{['completed', 'canceled', 'error'].includes(item.phase) && (
<button onClick={() => dispatch({ type: 'remove', localId: item.localId })}>
Remove
</button>
)}
</div>
)
}import type { UploadItem, UploadCommand } from '@gentleduck/upload'
function UploadItemRow({
item,
dispatch,
}: {
item: UploadItem<any, any, any, any>
dispatch: (cmd: UploadCommand<string>) => void
}) {
const filename = item.file?.name ?? 'Unknown file'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0' }}>
{/* Filename */}
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{filename}
</span>
{/* Phase-specific UI */}
{item.phase === 'validating' && <span>Validating...</span>}
{item.phase === 'creating_intent' && <span>Preparing...</span>}
{item.phase === 'ready' && (
<button onClick={() => dispatch({ type: 'start', localId: item.localId })}>
Start
</button>
)}
{item.phase === 'queued' && <span>Queued...</span>}
{item.phase === 'uploading' && (
<>
<div style={{ width: 120, height: 8, backgroundColor: '#e5e7eb', borderRadius: 4 }}>
<div
style={{
width: `${item.progress.pct}%`,
height: '100%',
backgroundColor: '#3b82f6',
borderRadius: 4,
transition: 'width 0.2s',
}}
/>
</div>
<span style={{ width: 50, textAlign: 'right' }}>{item.progress.pct.toFixed(0)}%</span>
<button onClick={() => dispatch({ type: 'pause', localId: item.localId })}>
Pause
</button>
</>
)}
{item.phase === 'paused' && (
<>
<span>Paused ({item.progress.pct.toFixed(0)}%)</span>
<button onClick={() => dispatch({ type: 'resume', localId: item.localId })}>
Resume
</button>
</>
)}
{item.phase === 'completing' && <span>Finalizing...</span>}
{item.phase === 'completed' && (
<span style={{ color: '#16a34a' }}>Done</span>
)}
{item.phase === 'error' && (
<>
<span style={{ color: '#dc2626' }}>{item.error.message}</span>
{item.retryable && (
<button onClick={() => dispatch({ type: 'retry', localId: item.localId })}>
Retry
</button>
)}
</>
)}
{/* Cancel button (for active uploads) */}
{!['completed', 'canceled', 'error'].includes(item.phase) && (
<button onClick={() => dispatch({ type: 'cancel', localId: item.localId })}>
Cancel
</button>
)}
{/* Remove button (for terminal states) */}
{['completed', 'canceled', 'error'].includes(item.phase) && (
<button onClick={() => dispatch({ type: 'remove', localId: item.localId })}>
Remove
</button>
)}
</div>
)
}Handle completion and errors with event listeners
You can use the on method from useUploader to show toast notifications or trigger side
effects:
import { useEffect } from 'react'
export function PhotoUploader() {
const { items, dispatch, on } = useUploader()
useEffect(() => {
const unsubCompleted = on('upload.completed', ({ localId, result }) => {
// Show a toast notification, update a gallery, etc.
console.log(`Upload ${localId} completed:`, result)
})
const unsubError = on('upload.error', ({ localId, error }) => {
console.error(`Upload ${localId} failed:`, error.message)
})
const unsubRejected = on('file.rejected', ({ file, reason }) => {
console.warn(`File ${file.name} rejected:`, reason)
})
return () => {
unsubCompleted()
unsubError()
unsubRejected()
}
}, [on])
// ... rest of component
}import { useEffect } from 'react'
export function PhotoUploader() {
const { items, dispatch, on } = useUploader()
useEffect(() => {
const unsubCompleted = on('upload.completed', ({ localId, result }) => {
// Show a toast notification, update a gallery, etc.
console.log(`Upload ${localId} completed:`, result)
})
const unsubError = on('upload.error', ({ localId, error }) => {
console.error(`Upload ${localId} failed:`, error.message)
})
const unsubRejected = on('file.rejected', ({ file, reason }) => {
console.warn(`File ${file.name} rejected:`, reason)
})
return () => {
unsubCompleted()
unsubError()
unsubRejected()
}
}, [on])
// ... rest of component
}How useSyncExternalStore Powers the React Binding
The useUploader hook is built on top of React's useSyncExternalStore. This is the
recommended way for external stores to integrate with React's concurrent features.
Here is a simplified version of what happens internally:
// Simplified from source
function useUploader(store) {
const snapshot = useSyncExternalStore(
store.subscribe.bind(store),
store.getSnapshot.bind(store),
store.getSnapshot.bind(store), // server snapshot (same for SSR)
)
const items = useMemo(
() => Array.from(snapshot.items.values()),
[snapshot.items],
)
const byPhase = useMemo(() => {
const result = {}
items.forEach((item) => {
if (!result[item.phase]) result[item.phase] = []
result[item.phase].push(item)
})
return result
}, [items])
return {
items,
byPhase,
dispatch: store.dispatch.bind(store),
on: store.on.bind(store),
off: store.off.bind(store),
uploading: byPhase.uploading || [],
paused: byPhase.paused || [],
completed: byPhase.completed || [],
failed: byPhase.error || [],
ready: byPhase.ready || [],
}
}// Simplified from source
function useUploader(store) {
const snapshot = useSyncExternalStore(
store.subscribe.bind(store),
store.getSnapshot.bind(store),
store.getSnapshot.bind(store), // server snapshot (same for SSR)
)
const items = useMemo(
() => Array.from(snapshot.items.values()),
[snapshot.items],
)
const byPhase = useMemo(() => {
const result = {}
items.forEach((item) => {
if (!result[item.phase]) result[item.phase] = []
result[item.phase].push(item)
})
return result
}, [items])
return {
items,
byPhase,
dispatch: store.dispatch.bind(store),
on: store.on.bind(store),
off: store.off.bind(store),
uploading: byPhase.uploading || [],
paused: byPhase.paused || [],
completed: byPhase.completed || [],
failed: byPhase.error || [],
ready: byPhase.ready || [],
}
}Key points:
store.subscribeis called by React to register a listener. When the store's state changes, React re-renders components that useuseUploader.store.getSnapshotreturns the current immutable state. React compares snapshots by reference to decide if a re-render is needed.- The
itemsandbyPhasevalues are memoized to avoid unnecessary re-renders downstream. - The
failedarray maps to theerrorphase (notfailed), matching the phase naming in the state machine.
Using useUploader Without a Provider
You can also pass a store directly to useUploader if you prefer not to use context:
import { useUploader } from '@gentleduck/upload'
import { uploadClient } from './upload'
function PhotoUploader() {
const { items, dispatch } = useUploader(uploadClient)
// ...
}import { useUploader } from '@gentleduck/upload'
import { uploadClient } from './upload'
function PhotoUploader() {
const { items, dispatch } = useUploader(uploadClient)
// ...
}This is useful for standalone components or when you have multiple upload stores in the same app.
The createUploadFactory Helper
For fully typed hook factories, use createUploadFactory:
import { createUploadFactory } from '@gentleduck/upload'
import { uploadClient } from './upload'
// Creates a typed hook bound to your specific store
export const usePhotoUploader = createUploadFactory(uploadClient)import { createUploadFactory } from '@gentleduck/upload'
import { uploadClient } from './upload'
// Creates a typed hook bound to your specific store
export const usePhotoUploader = createUploadFactory(uploadClient)function PhotoUploader() {
// Fully typed -- knows about PhotoIntentMap, PhotoPurpose, etc.
const { items, dispatch } = usePhotoUploader()
// ...
}function PhotoUploader() {
// Fully typed -- knows about PhotoIntentMap, PhotoPurpose, etc.
const { items, dispatch } = usePhotoUploader()
// ...
}The useUploaderActions Hook
If you only need dispatch and events (no reactive state), use useUploaderActions:
import { useUploaderActions } from '@gentleduck/upload'
function UploadButton() {
const { dispatch, on, store } = useUploaderActions()
return (
<button onClick={() => dispatch({ type: 'startAll' })}>
Start All Uploads
</button>
)
}import { useUploaderActions } from '@gentleduck/upload'
function UploadButton() {
const { dispatch, on, store } = useUploaderActions()
return (
<button onClick={() => dispatch({ type: 'startAll' })}>
Start All Uploads
</button>
)
}This avoids re-rendering the component on every state change, since it does not subscribe to snapshot updates.
Checkpoint
Your project should look like this:
photoduck/
src/
upload.ts -- types + api + client
App.tsx -- UploadProvider wrapper
PhotoUploader.tsx -- dropzone + progress bars + controls
package.json
tsconfig.json
Full src/App.tsx
import { UploadProvider } from '@gentleduck/upload'
import { uploadClient } from './upload'
import { PhotoUploader } from './PhotoUploader'
export function App() {
return (
<UploadProvider store={uploadClient}>
<div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
<h1>PhotoDuck</h1>
<PhotoUploader />
</div>
</UploadProvider>
)
}import { UploadProvider } from '@gentleduck/upload'
import { uploadClient } from './upload'
import { PhotoUploader } from './PhotoUploader'
export function App() {
return (
<UploadProvider store={uploadClient}>
<div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
<h1>PhotoDuck</h1>
<PhotoUploader />
</div>
</UploadProvider>
)
}Full src/PhotoUploader.tsx
import { useUploader } from '@gentleduck/upload'
import type { UploadItem, UploadCommand } from '@gentleduck/upload'
import { useCallback, useEffect, useRef, useState } from 'react'
export function PhotoUploader() {
const { items, dispatch, on, uploading, completed, failed } = useUploader()
const inputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const addFiles = useCallback((files: File[]) => {
if (files.length > 0) {
dispatch({ type: 'addFiles', files, purpose: 'photo' })
}
}, [dispatch])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
addFiles(Array.from(e.dataTransfer.files))
}, [addFiles])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback(() => {
setIsDragging(false)
}, [])
// Event listeners for side effects
useEffect(() => {
const unsubCompleted = on('upload.completed', ({ localId, result }) => {
console.log(`Upload ${localId} completed:`, result)
})
const unsubError = on('upload.error', ({ localId, error }) => {
console.error(`Upload ${localId} failed:`, error.message)
})
return () => {
unsubCompleted()
unsubError()
}
}, [on])
return (
<div>
{/* Summary */}
<p>
{items.length} files | {uploading.length} uploading | {completed.length} done | {failed.length} failed
</p>
{/* Dropzone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${isDragging ? '#3b82f6' : '#d1d5db'}`,
borderRadius: 8,
padding: 40,
textAlign: 'center',
cursor: 'pointer',
backgroundColor: isDragging ? '#eff6ff' : 'transparent',
transition: 'all 0.2s',
}}
>
<p>{isDragging ? 'Drop files here' : 'Click or drag files to upload'}</p>
</div>
<input
ref={inputRef}
type="file"
multiple
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
addFiles(Array.from(e.target.files ?? []))
e.target.value = ''
}}
/>
{/* Bulk actions */}
{items.length > 0 && (
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button onClick={() => dispatch({ type: 'startAll' })}>Start All</button>
<button onClick={() => dispatch({ type: 'pauseAll' })}>Pause All</button>
<button onClick={() => dispatch({ type: 'cancelAll' })}>Cancel All</button>
</div>
)}
{/* Upload list */}
<div style={{ marginTop: 20 }}>
{items.map((item) => (
<UploadItemRow key={item.localId} item={item} dispatch={dispatch} />
))}
</div>
</div>
)
}
function UploadItemRow({
item,
dispatch,
}: {
item: UploadItem<any, any, any, any>
dispatch: (cmd: UploadCommand<string>) => void
}) {
const filename = item.file?.name ?? 'Unknown file'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid #f3f4f6' }}>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{filename}
</span>
{item.phase === 'validating' && <span style={{ color: '#6b7280' }}>Validating...</span>}
{item.phase === 'creating_intent' && <span style={{ color: '#6b7280' }}>Preparing...</span>}
{item.phase === 'ready' && (
<button onClick={() => dispatch({ type: 'start', localId: item.localId })}>Start</button>
)}
{item.phase === 'queued' && <span style={{ color: '#6b7280' }}>Queued</span>}
{item.phase === 'uploading' && (
<>
<div style={{ width: 120, height: 8, backgroundColor: '#e5e7eb', borderRadius: 4 }}>
<div
style={{
width: `${item.progress.pct}%`,
height: '100%',
backgroundColor: '#3b82f6',
borderRadius: 4,
transition: 'width 0.2s',
}}
/>
</div>
<span style={{ width: 50, textAlign: 'right', fontSize: 14 }}>
{item.progress.pct.toFixed(0)}%
</span>
<button onClick={() => dispatch({ type: 'pause', localId: item.localId })}>Pause</button>
</>
)}
{item.phase === 'paused' && (
<>
<span style={{ color: '#f59e0b' }}>Paused</span>
<button onClick={() => dispatch({ type: 'resume', localId: item.localId })}>Resume</button>
</>
)}
{item.phase === 'completing' && <span style={{ color: '#6b7280' }}>Finalizing...</span>}
{item.phase === 'completed' && <span style={{ color: '#16a34a' }}>Done</span>}
{item.phase === 'error' && (
<>
<span style={{ color: '#dc2626' }}>{item.error.message}</span>
{item.retryable && (
<button onClick={() => dispatch({ type: 'retry', localId: item.localId })}>Retry</button>
)}
</>
)}
{!['completed', 'canceled', 'error'].includes(item.phase) && (
<button onClick={() => dispatch({ type: 'cancel', localId: item.localId })}>Cancel</button>
)}
{['completed', 'canceled', 'error'].includes(item.phase) && (
<button onClick={() => dispatch({ type: 'remove', localId: item.localId })}>Remove</button>
)}
</div>
)
}import { useUploader } from '@gentleduck/upload'
import type { UploadItem, UploadCommand } from '@gentleduck/upload'
import { useCallback, useEffect, useRef, useState } from 'react'
export function PhotoUploader() {
const { items, dispatch, on, uploading, completed, failed } = useUploader()
const inputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const addFiles = useCallback((files: File[]) => {
if (files.length > 0) {
dispatch({ type: 'addFiles', files, purpose: 'photo' })
}
}, [dispatch])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
addFiles(Array.from(e.dataTransfer.files))
}, [addFiles])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback(() => {
setIsDragging(false)
}, [])
// Event listeners for side effects
useEffect(() => {
const unsubCompleted = on('upload.completed', ({ localId, result }) => {
console.log(`Upload ${localId} completed:`, result)
})
const unsubError = on('upload.error', ({ localId, error }) => {
console.error(`Upload ${localId} failed:`, error.message)
})
return () => {
unsubCompleted()
unsubError()
}
}, [on])
return (
<div>
{/* Summary */}
<p>
{items.length} files | {uploading.length} uploading | {completed.length} done | {failed.length} failed
</p>
{/* Dropzone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${isDragging ? '#3b82f6' : '#d1d5db'}`,
borderRadius: 8,
padding: 40,
textAlign: 'center',
cursor: 'pointer',
backgroundColor: isDragging ? '#eff6ff' : 'transparent',
transition: 'all 0.2s',
}}
>
<p>{isDragging ? 'Drop files here' : 'Click or drag files to upload'}</p>
</div>
<input
ref={inputRef}
type="file"
multiple
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
addFiles(Array.from(e.target.files ?? []))
e.target.value = ''
}}
/>
{/* Bulk actions */}
{items.length > 0 && (
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button onClick={() => dispatch({ type: 'startAll' })}>Start All</button>
<button onClick={() => dispatch({ type: 'pauseAll' })}>Pause All</button>
<button onClick={() => dispatch({ type: 'cancelAll' })}>Cancel All</button>
</div>
)}
{/* Upload list */}
<div style={{ marginTop: 20 }}>
{items.map((item) => (
<UploadItemRow key={item.localId} item={item} dispatch={dispatch} />
))}
</div>
</div>
)
}
function UploadItemRow({
item,
dispatch,
}: {
item: UploadItem<any, any, any, any>
dispatch: (cmd: UploadCommand<string>) => void
}) {
const filename = item.file?.name ?? 'Unknown file'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid #f3f4f6' }}>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{filename}
</span>
{item.phase === 'validating' && <span style={{ color: '#6b7280' }}>Validating...</span>}
{item.phase === 'creating_intent' && <span style={{ color: '#6b7280' }}>Preparing...</span>}
{item.phase === 'ready' && (
<button onClick={() => dispatch({ type: 'start', localId: item.localId })}>Start</button>
)}
{item.phase === 'queued' && <span style={{ color: '#6b7280' }}>Queued</span>}
{item.phase === 'uploading' && (
<>
<div style={{ width: 120, height: 8, backgroundColor: '#e5e7eb', borderRadius: 4 }}>
<div
style={{
width: `${item.progress.pct}%`,
height: '100%',
backgroundColor: '#3b82f6',
borderRadius: 4,
transition: 'width 0.2s',
}}
/>
</div>
<span style={{ width: 50, textAlign: 'right', fontSize: 14 }}>
{item.progress.pct.toFixed(0)}%
</span>
<button onClick={() => dispatch({ type: 'pause', localId: item.localId })}>Pause</button>
</>
)}
{item.phase === 'paused' && (
<>
<span style={{ color: '#f59e0b' }}>Paused</span>
<button onClick={() => dispatch({ type: 'resume', localId: item.localId })}>Resume</button>
</>
)}
{item.phase === 'completing' && <span style={{ color: '#6b7280' }}>Finalizing...</span>}
{item.phase === 'completed' && <span style={{ color: '#16a34a' }}>Done</span>}
{item.phase === 'error' && (
<>
<span style={{ color: '#dc2626' }}>{item.error.message}</span>
{item.retryable && (
<button onClick={() => dispatch({ type: 'retry', localId: item.localId })}>Retry</button>
)}
</>
)}
{!['completed', 'canceled', 'error'].includes(item.phase) && (
<button onClick={() => dispatch({ type: 'cancel', localId: item.localId })}>Cancel</button>
)}
{['completed', 'canceled', 'error'].includes(item.phase) && (
<button onClick={() => dispatch({ type: 'remove', localId: item.localId })}>Remove</button>
)}
</div>
)
}Chapter 3 FAQ
Does useUploader re-render on every progress event?
Yes, useUploader re-renders when the snapshot changes, which includes progress updates.
However, the engine throttles progress events via progressThrottleMs (default around
250ms), so you get smooth updates without flooding React. If you need a component that
does not re-render on progress, use useUploaderActions instead -- it only provides
dispatch and on without subscribing to state changes.
Can I have multiple UploadProviders for different upload areas?
Yes. Create separate upload clients with createUploadClient and wrap each area with its
own UploadProvider. Alternatively, use a single client with different purposes to
categorize uploads, and filter by purpose in your UI. Multiple providers are useful when
you need completely independent upload pipelines (different backends, different strategies).
Does this work with server-side rendering (Next.js, Remix)?
Both UploadProvider and useUploader are marked as 'use client' components. They work
in client components in Next.js App Router. The upload client should be created in a client
module. useSyncExternalStore provides a server snapshot (the same getSnapshot) for SSR
hydration, so there are no hydration mismatches. Just make sure the upload client module
is not imported during server rendering.
Do I have to use UploadProvider?
No. You can pass the store directly to useUploader(store). The provider is a convenience
for apps where many components need access to the same store. If only one component uses
uploads, passing the store directly is simpler. You can also use createUploadFactory to
create a typed hook pre-bound to a specific store, which avoids both context and prop
drilling.
Why is the failed array items in the 'error' phase, not 'failed'?
The state machine uses error as the phase name because it is a state, not an outcome.
Items in the error phase may be retryable -- they are not necessarily "failed" forever.
The useUploader hook exposes them as failed for convenience in UI code, but the
underlying phase is error. When you check item.phase, use 'error', not 'failed'.
Should I use a drag-and-drop library?
The native HTML5 drag-and-drop API works well for file uploads and is what we used above.
Libraries like react-dropzone add niceties like file type filtering via the accept
attribute, better cross-browser behavior, and accessibility. You can use any library --
all you need is an array of File objects to pass to dispatch({ type: 'addFiles' }).
The upload engine does not care how you obtain the files.