Skip to content

XHR with progress

XMLHttpRequest is the right default when the UI needs a percent. xhr.upload exposes request-body progress; fetch does not.

ts
import type { UploadAdapter } from 'react-upload-kit';

interface UploadResponse {
  url: string;
}

export const xhrAdapter: UploadAdapter<UploadResponse> = (file, { onProgress, signal }) => {
  const body = new FormData();
  body.append('file', file);

  const xhr = new XMLHttpRequest();

  return new Promise((resolve, reject) => {
    const onAbort = () => {
      xhr.abort();
      reject(new DOMException('Upload aborted', 'AbortError'));
    };

    if (signal.aborted) {
      onAbort();
      return;
    }

    signal.addEventListener('abort', onAbort, { once: true });

    xhr.upload.addEventListener('progress', (event) => {
      if (event.lengthComputable) {
        onProgress(Math.round((event.loaded / event.total) * 100));
      }
    });

    xhr.addEventListener('load', () => {
      signal.removeEventListener('abort', onAbort);
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText) as UploadResponse);
      } else {
        reject(new Error(`Upload failed: ${xhr.status}`));
      }
    });

    xhr.addEventListener('error', () => {
      signal.removeEventListener('abort', onAbort);
      reject(new Error('Network error'));
    });

    xhr.open('POST', '/api/upload');
    xhr.send(body);
  });
};

Wire it in:

tsx
const uploader = useUploader<UploadResponse>({
  adapter: xhrAdapter,
  autoUpload: true,
  concurrency: 3,
  maxRetries: 2,
});

Notes

  • Call onProgress with integers 0–100. The library does not clamp; keep the value in range.
  • Listen to signal even if you never show a Cancel button — removeFile and clearAll abort too.
  • Parse and type the JSON yourself. The adapter is the boundary; do not leak XMLHttpRequest into React state.

See Adapters for fetch vs XHR vs S3.

Released under the MIT License.