Skip to main content

Chapter 3: React Integration

WIP

Build 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.

Loading diagram...

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:

src/upload.ts
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:

src/upload.ts
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

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

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

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

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

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

Chapter 3 FAQ


Next: Chapter 4: Multipart Uploads