Agora
React Client

React Client

@adonis-agora/media-react — a React hook (useMediaUpload), a headless-friendly MediaUploader component, and a framework-free browser upload client (createMediaUploadClient) that speak the provider's actual upload contract across TUS, direct-S3 multipart, and proxy strategies.

@adonis-agora/media-react is the first-party browser companion to the server upload routes. It speaks the AdonisJS provider's actual HTTP contract across all three strategies:

  • TUS resumable (default) — POST / HEAD / PATCH / DELETE against tusPath (default /media/uploads/tus, matching uploads.resumable.routes.prefix), resuming from the server's Upload-Offset.
  • Direct-S3 multipart — the direct-session contract against directPath (default /media/uploads/direct/sessions, matching uploads.direct.routes.prefix): POST {directPath} initiates and returns presigned part URLs, each part PUTs straight to S3, POST {directPath}/:id/parts/:partNumber confirms its ETag, and POST {directPath}/:id/complete assembles the object. GET {directPath}/:id reports a session's state and DELETE {directPath}/:id tears it down.
  • Proxy — a single PUT {uploadsPath}/proxy streams the whole body through the app.

Two paths, because there are two server prefixes

uploadsPath and directPath are separate options because they address separate mounts on the server: the core upload routes (uploads.routes.prefix, default /media/uploads, where the proxy endpoint lives) and the direct-session routes (uploads.direct.routes.prefix, default /media/uploads/direct/sessions). Each client option defaults to the matching server default, so a client talking to a stock server sets neither.

Set directPath when you moved the server's direct-session prefix:

createMediaUploadClient({ directPath: '/api/v1/upload-video' })

An explicit uploadsPath also supplies directPath when you don't set one, which is how clients were configured before directPath existed — so a single-path setup keeps working untouched.

The older stateless routes under uploads.routes.prefix (POST /direct/initiate, POST /direct/:uploadId/complete) exist too, but this client does not speak them. They are the raw primitives documented in Upload modes; the client always uses the session-backed contract, because that is the one that survives a page reload.

Separate package

Install it alongside the server library: npm i @adonis-agora/media-react react. react is an optional peer — the ./client entry point is framework-free and needs no React. This package matches the routes documented in Upload modes and Resumable / TUS.


useMediaUpload

A hook with progress / status state and pause / resume / abort. For tus, resume continues from the server offset; for direct / proxy a resume restarts the transfer.

import { useMediaUpload } from '@adonis-agora/media-react'

function Uploader() {
  const { upload, pause, resume, abort, status, progress } = useMediaUpload({ mode: 'tus' })

  return (
    <div>
      <input
        type="file"
        onChange={(e) => {
          const file = e.target.files?.[0]
          if (file) upload(file, { filename: file.name, contentType: file.type })
        }}
      />
      <progress value={progress} max={1} />
      <span>{status}</span>
    </div>
  )
}
  • mode: 'tus' (resumable, default) · 'direct' (presigned S3 multipart) · 'proxy'.
  • State: status ('idle' | 'uploading' | 'paused' | 'success' | 'error'), progress (0..1), result, location (TUS session), error, resumable (see below).
  • Actions: upload(file, meta), pause(), resume(), abort() (terminates the session server-side), reset().
  • Only proxy takes a key in the upload meta. TUS and direct both derive the key server-side, and meta.key is ignored on those paths — a client-chosen key would be a hole you'd have to close anyway.

The hook accepts every MediaUploadClientOptions field (below) plus an optional pre-built client for tests or a shared instance, and a storageKey for cross-reload resume.

The typed completion body

useMediaUpload<TComplete>() types what the server's complete returned. For a direct upload with a DirectUploadPolicy, that body is whatever the policy's onComplete handed back — a media id, a status, a whole domain object:

type Completion = { mediaId: string; status: 'processing' }

const { upload } = useMediaUpload<Completion>({ mode: 'direct' })

const result = await upload(file, { filename: file.name, contentType: file.type })
if (result.mode === 'direct') {
  router.visit(`/videos/${result.body.mediaId}`) // typed
}

The result is a discriminated union — { mode: 'tus', location }, { mode: 'direct', uploadId, key, disk, body }, { mode: 'proxy', key, disk } — so narrowing on mode is what gets you to body at all.

TComplete is an assertion, not a validation. That body is raw server JSON and nothing at this boundary checks it. Parse it with a schema before trusting its shape.

Pausing and resuming

pause() aborts the in-flight transfer and settles into status: 'paused'; resume() picks the same file back up. What "resume" means depends on the strategy:

  • tus — a true byte-offset resume. The client re-HEADs the session, learns the server's offset, and continues from exactly there.
  • direct — a real resume at part granularity. pause aborts the in-flight part PUTs; resume calls directSessionStatus and continues from the session's still-pending parts with fresh presigned URLs. Parts already confirmed are never re-uploaded.
  • proxy — a single-shot transfer, so a resume restarts it.

abort() differs from reset() in one important way: abort tears the session down server-side (a TUS DELETE, a direct-session DELETE) as well as clearing local state, while reset only clears locally. Reset an upload you might resume; abort one you are done with.

Resuming a direct upload across a reload

Pause/resume survives a component unmount but not a page reload — the session coordinates live in memory. Pass storageKey and they are persisted to localStorage on initiate:

const { upload, resume, resumable, status, progress } = useMediaUpload({
  mode: 'direct',
  storageKey: `upload:lesson:${lessonId}`,
})

return resumable ? (
  <button type="button" onClick={() => resume()}>
    Resume “{resumable.fileName}”
  </button>
) : (
  <input type="file" onChange={(e) => { /* upload(file, meta) */ }} />
)

How it behaves, because the details are what make it trustworthy:

  • On mount, the hook probes the persisted session. If the server still has pending parts, resumable is populated with { uploadId, fileName } — your cue to offer "resume" instead of "choose a file". If the session is finished or gone, the entry is dropped silently.
  • On upload() of the same file, a persisted session is reused when the file name and the byte size both match. Requiring the size too is what stops a revised same-named file from splicing its bytes into the old session.
  • The server is the authority. On resume the client re-reads the coordinates from directSessionStatus — key, disk, part size, pending parts — rather than trusting the stored copy.
  • A definitive "gone" (404 / 410) drops the entry and falls through to a fresh initiate, so an expired session never bricks that storageKey. A transient failure (network blip, 5xx) keeps the entry resumable and surfaces the error instead of silently discarding progress and restarting at 0%. Telling those two apart is exactly what MediaHttpError.status is for.
  • The entry is cleared on success, on abort() and on reset().

Because resume() needs the original Blob back, offer it from a control that still has the file (or re-prompt for it) — the hook persists the session's coordinates, never the bytes. storageKey is ignored by tus and proxy, and should be unique per upload slot: two uploaders sharing one key would fight over the same entry.


MediaUploader

A minimal, headless-friendly component wired to the hook. The default markup is a file input + progress bar; pass render to take full control.

import { MediaUploader } from '@adonis-agora/media-react'

<MediaUploader mode="tus" accept="image/*" onUploaded={(r) => console.log(r)} />

// Fully headless — bring your own UI:
<MediaUploader
  mode="direct"
  render={({ status, progress, selectFile }) => (
    /* your markup; call selectFile(file) to start */
  )}
/>
PropTypeWhat
mode'tus' | 'direct' | 'proxy'Strategy (default tus).
acceptstringaccept attribute for the file input.
metaPartial<UploadMeta>Extra metadata merged into every upload (key / disk for proxy, metadata / tusPath for TUS).
unstyledbooleanSkip the default stylesheet.
classNamestringClass on the wrapper element.
onUploaded / onErrorcallbacksFire on success / failure.
renderfunctionHeadless override receiving the hook state + selectFile(file).

The default styling uses Agora design tokens (--agora-primary, --agora-primary-soft, --agora-ink) through overridable --agora-media-* CSS custom properties — retheme by setting a variable, or pass unstyled to drop the stylesheet. No vendor branding. (ensureMediaUploaderStyles / mediaUploaderCss / MEDIA_UPLOADER_STYLE_ID are exported if you manage the stylesheet yourself.)


createMediaUploadClient (framework-free)

The ./client entry point needs no React and drives the same three strategies directly.

import { createMediaUploadClient } from '@adonis-agora/media-react/client'

const client = createMediaUploadClient({
  baseUrl: 'https://api.example.com',
  headers: { Authorization: `Bearer ${token}` },     // merged into every app request
  getHeaders: async () => ({ Authorization: `Bearer ${await fresh()}` }), // short-lived tokens
})

await client.uploadTus(file, { filename: 'a.png', contentType: 'image/png' })
await client.uploadDirect(file, { filename: 'a.png', contentType: 'image/png' }) // key: server-side
await client.uploadProxy(file, { filename: 'a.png', key: 'u/1/a.png' })          // key: required

Those are the three upload entry points; the client also exposes the session primitives underneath them (createTusSession, tusOffset, abortTus, directSessionStatus, abortDirectSession) and an injectable part transport. See Upload client for the full surface.

OptionDefaultWhat
baseUrl'' (same-origin)Origin (and optional path) prepended to every relative endpoint.
tusPath/media/uploads/tusTUS base path — match uploads.resumable.routes.prefix.
uploadsPath/media/uploadsCore upload routes — where the proxy endpoint lives. Match uploads.routes.prefix.
directPath/media/uploads/direct/sessionsDirect-session routes. Match uploads.direct.routes.prefix. Falls back to uploadsPath when that is set and this isn't.
chunkSize8 MiBBytes per TUS chunk. The direct-S3 part size is decided by the server, not here.
concurrencyReserved. Direct-S3 uploads are sequential today, so this does not apply to them; it is kept for forward compatibility.
retries3Per-chunk / per-part retry attempts.
fetchImplglobal fetchCustom fetch for tests / non-browser runtimes.
partUploaderxhrPartUploaderTransport for one direct-S3 part PUT. See Upload client.
headersStatic headers merged into every app request.
getHeadersResolved fresh before every app request (short-lived tokens); wins on key conflict.

UploadMeta

FieldApplies toWhat
filenameallRequired. Sent as the TUS filename metadata / the direct fileName.
contentTypeallMIME type. TUS sends it as filetype; direct declares it at initiate, where a collection gate may reject it.
sizeallTotal byte length. Defaults to the blob's .size.
keyproxy onlyThe object key. Ignored by TUS and direct, which derive the key server-side.
diskproxy onlyDisk override. A direct upload's disk is assigned by the server at initiate.
metadataTUS onlyExtra Upload-Metadata pairs carried through to your server. See Upload client.
tusPathTUS onlyPer-upload override of the create path, for a TUS route that embeds a resource id.

headers / getHeaders are merged into requests against your app only. Presigned S3 PUTs deliberately receive neither — adding an Authorization header there would break the SigV4 signature.

A mediaUrl(id, conversion?) helper is also exported for composing URLs against the base path.


Next steps

On this page