Skip to main content
Search...

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.

Loading diagram...

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:

src/App.tsx
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>
  )
}
src/App.tsx
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:

src/PhotoUploader.tsx
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>
  )
}
src/PhotoUploader.tsx
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:

FieldTypeDescription
itemsUploadItem[]All upload items as an array
byPhaseRecord<string, UploadItem[]>Items grouped by phase
dispatch(cmd) => voidDispatch commands (same as uploadClient.dispatch)
on(event, cb) => unsubSubscribe to events
off(event, cb) => unsubUnsubscribe from events
uploadingUploadItem[]Items in uploading phase
pausedUploadItem[]Items in paused phase
completedUploadItem[]Items in completed phase
failedUploadItem[]Items in error phase
readyUploadItem[]Items in ready phase

Build a file input / dropzone component

Add a file input and a drag-and-drop zone:

src/PhotoUploader.tsx
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>
  )
}
src/PhotoUploader.tsx
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:

src/PhotoUploader.tsx
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>
  )
}
src/PhotoUploader.tsx
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:

src/PhotoUploader.tsx
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
}
src/PhotoUploader.tsx
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.subscribe is called by React to register a listener. When the store's state changes, React re-renders components that use useUploader.
  • store.getSnapshot returns the current immutable state. React compares snapshots by reference to decide if a re-render is needed.
  • The items and byPhase values are memoized to avoid unnecessary re-renders downstream.
  • The failed array maps to the error phase (not failed), 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.


Next: Chapter 4: Multipart Uploads