Skip to content

Adapters

The adapter is the only backend-facing contract in the library. Everything else — queue, retry, UI — is independent of how bytes leave the browser.

This is the right seam. Upload destinations change (your API, S3, tus, a vendor SDK). The file list and dropzone should not.

Contract

ts
type UploadAdapter<TResponse> = (
  file: File,
  context: {
    onProgress: (percent: number) => void;
    signal: AbortSignal;
  },
) => Promise<TResponse>;
ResponsibilityHow
Upload the fileReturn a Promise that resolves with whatever your backend returns.
Report progressCall onProgress with a 0–100 integer. Optional, but required for a useful progress bar.
Honour cancellationAbort the request when signal fires. Throw or reject; the queue treats abort as cancel, not as a retryable error.
Surface failuresthrow / reject with an Error. That becomes file.error.

TResponse flows end-to-end: adapter → UploadFile.responseonUploadSuccess. Type it once on useUploader<TResponse>.

Keep the adapter stable

useUploader closes over adapter when enqueueing work. Recreating the function every render can surprise you (new identity, stale closures). Memoize it:

tsx
const adapter = useMemo(() => createXhrAdapter('/api/upload'), []);

const uploader = useUploader({ adapter });

The demo does the same with a mock adapter so the queue always talks to one function instance.

Choose a transport

TransportUpload progressCancelWhen to use
XMLHttpRequestYes (xhr.upload)xhr.abort()Default for form uploads with a progress bar.
fetchNo (request body progress is not exposed)signalSimple APIs where a spinner is enough.
S3 presigned PUTYes, via XHRYesDirect-to-bucket uploads; your API only mints the URL.
tus / resumableYesYesLarge files, flaky networks. Wrap the client in an adapter.

fetch is shorter, but it cannot report upload progress. If the UI needs a percent, use XHR (or a wrapper around it). That is a browser limitation, not a library one.

Error vs abort

  • Failure: reject with Error. The file moves to error. Automatic retries apply if maxRetries > 0.
  • Abort: signal aborted. The file moves to cancelled. Automatic retries do not run; the user can call retryFile.

In the adapter, listen once and tear down:

ts
signal.addEventListener('abort', () => xhr.abort());

Do not treat AbortError as a business failure. The queue already distinguishes abort from a thrown error.

Typing the response

ts
interface UploadResult {
  url: string;
  id: string;
}

const uploader = useUploader<UploadResult>({
  adapter: async (file, ctx) => {
    // ...
    return { url: '...', id: '...' };
  },
});

uploader.files[0]?.response?.url; // string | undefined

Keep the adapter return value small and stable. UI should not depend on transport-specific fields leaking into TResponse.

Next

Released under the MIT License.