## 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`:

```typescript title="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`:

```typescript title="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**

```tsx title="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**

```tsx title="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**

```tsx title="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.

```tsx title="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:

```tsx title="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:

```tsx
const { items, dispatch } = useUploader(store)
```

For buttons that shouldn't re-render on progress, `useUploaderActions` returns `{ dispatch, on,
store }` without subscribing to snapshots:

```tsx
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

Does useUploader re-render on every progress event?

It re-renders when the snapshot changes, which includes progress. The engine throttles
progress with `config.progressThrottleMs` (default `100`), keeping it smooth. For a
component that must not re-render on progress, use `useUploaderActions`.

Can I have multiple providers?

Yes — create separate stores with `createUploadStore` and nest a `UploadProvider` per area.
Often a single store with different purposes is enough; use multiple stores when pipelines
are truly independent (different backends or persistence keys).

Does it work with SSR (Next.js)?

Use the hooks in client components. `useSyncExternalStore` supplies a server snapshot
(the same `getSnapshot`) so there are no hydration mismatches. Keep the store module out of
server rendering paths.

Why is `failed` the `error` phase?

`error` is a state, not a verdict — such items may be retryable. The hook exposes them as
`failed` for convenience, but when you check `item.phase`, use `'error'`.

***

Next: [Chapter 4: Multipart Uploads](/duck-upload/course/chapter-4)