Chapter 3: React Integration
WIPBuild a dropzone UI with UploadProvider and useUploader, complete with progress and controls.
Goal
Turn the store into a React UI: a dropzone, a live list with progress bars, and per-item
controls — all through UploadProvider and useUploader.
Step by step
Reuse your types
The React binding is generic over the same <M, C, P, R>. Export them from src/upload.ts
so components can pass them to useUploader:
export type PhotoIntents = { post: PostStrategy.Intent }
export type PhotoCursors = { post?: PostStrategy.Cursor }
export type PhotoPurpose = 'photo'
export type PhotoResult = Contracts.Result.Base & { url: string }
A tidy alternative — bind a typed hook once with createUploadFactory:
import { createUploadFactory } from '@gentleduck/upload/react'
export const usePhotoUploader = createUploadFactory(store)
usePhotoUploader() is a useUploader pre-typed to your store — no generics or context
needed at the call site.
Wrap the app
import { UploadProvider } from '@gentleduck/upload/react'
import { store } from './upload'
import { PhotoUploader } from './PhotoUploader'
export function App() {
return (
<UploadProvider store={store}>
<h1>PhotoDuck</h1>
<PhotoUploader />
</UploadProvider>
)
}
Read state with useUploader
import { useUploader } from '@gentleduck/upload/react'
import type { PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult } from './upload'
export function PhotoUploader() {
const { items, uploading, completed, failed, dispatch } =
useUploader<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
return (
<p>
{items.length} files · {uploading.length} uploading · {completed.length} done ·{' '}
{failed.length} failed
</p>
)
}
The hook returns items, the byPhase grouping, ready-made buckets (uploading, paused,
completed, failed, ready), and dispatch/on/off. failed is the error phase.
Add a dropzone
import { useCallback, useRef, useState } from 'react'
function Dropzone({ onFiles }: { onFiles: (files: File[]) => void }) {
const inputRef = useRef<HTMLInputElement>(null)
const [dragging, setDragging] = useState(false)
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
onFiles(Array.from(e.dataTransfer.files))
}, [onFiles])
return (
<>
<div
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onClick={() => inputRef.current?.click()}
style={{ border: `2px dashed ${dragging ? '#3b82f6' : '#d1d5db'}`, padding: 40, textAlign: 'center', cursor: 'pointer' }}
>
{dragging ? 'Drop files here' : 'Click or drag files to upload'}
</div>
<input
ref={inputRef}
type="file"
multiple
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => { onFiles(Array.from(e.target.files ?? [])); e.target.value = '' }}
/>
</>
)
}
Render items with progress and controls
Engine.Item is a discriminated union — narrow on phase before reading progress,
error, etc.
import type { Engine } from '@gentleduck/upload/core'
type Item = Engine.Item<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>
type Dispatch = (cmd: Engine.Command<PhotoPurpose>) => void
function Row({ item, dispatch }: { item: Item; dispatch: Dispatch }) {
return (
<div style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '8px 0' }}>
<span style={{ flex: 1 }}>{item.fingerprint.name}</span>
{item.phase === 'ready' && (
<button onClick={() => dispatch({ type: 'start', localId: item.localId })}>Start</button>
)}
{item.phase === 'uploading' && (
<>
<progress value={item.progress.pct} max={100} />
<span>{item.progress.pct.toFixed(0)}%</span>
<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 === '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>
)}
</>
)}
{item.phase !== 'completed' && item.phase !== 'canceled' && (
<button onClick={() => dispatch({ type: 'cancel', localId: item.localId })}>Cancel</button>
)}
</div>
)
}
React to events for side effects
Use on from the hook. It returns its own unsubscribe function:
import { useEffect } from 'react'
function Notifications() {
const { on } = useUploader<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
useEffect(() => {
const offDone = on('upload.completed', ({ localId }) => console.log('done', localId))
const offRejected = on('file.rejected', ({ file, reason }) => console.warn(file.name, reason))
return () => { offDone(); offRejected() }
}, [on])
return null
}
How the binding works
useUploader is built on useSyncExternalStore. It passes store.subscribe and
store.getSnapshot to React, so the component re-renders whenever the immutable snapshot
changes. items and byPhase are memoized. Because snapshots are compared by reference, a
render only happens when something actually changed.
Provider-free and action-only variants
Pass a store directly to skip context:
const { items, dispatch } = useUploader(store)
For buttons that shouldn't re-render on progress, useUploaderActions returns { dispatch, on, store } without subscribing to snapshots:
function StartAll() {
const { dispatch } = useUploaderActions<PhotoIntents, PhotoCursors, PhotoPurpose, PhotoResult>()
return <button onClick={() => dispatch({ type: 'startAll' })}>Start all</button>
}
Checkpoint
photoduck/
src/
upload.ts -- types + store (+ optional usePhotoUploader)
App.tsx -- UploadProvider
PhotoUploader.tsx -- dropzone + rows + controls