Angular File Uploads and Progress: Drag-Drop, Multipart, HttpClient Events [2026]

Link copied
Angular File Uploads and Progress: Drag-Drop, Multipart, HttpClient Events [2026]

Angular File Uploads and Progress: Drag-Drop, Multipart, HttpClient Events [2026]

Angular Tutorial Module 4: Forms Lesson 4.5

File uploads are the ceiling test for any forms implementation. They cross three boundaries — DOM API (FileList), HTTP (multipart/form-data), and UI feedback (progress bars). Angular gives you primitives for all three; this lesson stitches them together into a working uploader with drag-drop, multi-file, and per-file progress.

This is lesson 4.5 of the Angular Tutorial. You should be comfortable with forms (lessons 4.1–4.4) and have HttpClient registered in app.config.ts (lesson 1.6) before reading on.

The basic <input type="file"> #

Native HTML gives you a file picker. The minimum Angular wrapper:

import { Component, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-uploader',
  template: `
    <input type="file" (change)="upload($event)" />
    @if (status()) { <p>{{ status() }}</p> }
  `,
})
export class UploaderComponent {
  private http = inject(HttpClient);
  status = signal('');

  upload(e: Event) {
    const file = ($any(e.target) as HTMLInputElement).files?.[0];
    if (!file) return;

    const form = new FormData();
    form.append('file', file);

    this.http.post('/api/upload', form).subscribe({
      next: () => this.status.set('Uploaded!'),
      error: (err) => this.status.set('Failed: ' + err.message),
    });
  }
}

Three facts:

  1. <input type="file"> value is a FileList (one per <input>). Use [0] for single-file mode.
  2. FormData is the standard multipart wrapper. form.append('field', file) becomes a Content-Disposition: form-data; name="field"; filename="X" part.
  3. HttpClient.post(url, FormData) auto-sets Content-Type: multipart/form-data with the right boundary. Do NOT set the header manually — you will break the request.

Multi-file selection #

Add the multiple attribute and iterate:

<input type="file" multiple (change)="uploadMany($event)" />
uploadMany(e: Event) {
  const files = Array.from(($any(e.target) as HTMLInputElement).files ?? []);
  for (const file of files) this.uploadOne(file);
}

uploadOne(file: File) {
  const form = new FormData();
  form.append('file', file);
  this.http.post('/api/upload', form).subscribe();
}

Each file uploads as a separate request. For server-side batching, append all to one FormData (same field name with [] suffix matches PHP/Express conventions) and post once.

Tracking upload progress #

The HttpClient supports progress events when you opt in:

import { HttpEvent, HttpEventType } from '@angular/common/http';

uploadWithProgress(file: File) {
  const form = new FormData();
  form.append('file', file);

  this.http.post('/api/upload', form, {
    reportProgress: true,    // opt-in
    observe: 'events',       // get the full event stream, not just the body
  }).subscribe((event: HttpEvent<unknown>) => {
    switch (event.type) {
      case HttpEventType.UploadProgress:
        const pct = event.total ? Math.round(100 * event.loaded / event.total) : 0;
        this.progress.set(pct);
        break;
      case HttpEventType.Response:
        this.status.set('Done');
        break;
    }
  });
}

Two flags do all the work:

  • reportProgress: true tells HttpClient to emit progress events
  • observe: 'events' changes the Observable from emitting only the body to emitting every lifecycle event

The event stream then includes Sent, ResponseHeader, UploadProgress, and Response events. Check event.type against HttpEventType enum values.

A complete uploader with per-file progress #

import { Component, signal, inject } from '@angular/core';
import { HttpClient, HttpEvent, HttpEventType } from '@angular/common/http';

interface Upload {
  file: File;
  progress: number;
  status: 'pending' | 'uploading' | 'done' | 'error';
  error?: string;
}

@Component({
  selector: 'app-multi-uploader',
  template: `
    <input type="file" multiple (change)="addFiles($event)" />
    <ul>
      @for (u of uploads(); track u.file.name) {
        <li>
          {{ u.file.name }} ({{ (u.file.size / 1024).toFixed(1) }} KB)
          <progress [value]="u.progress" max="100"></progress>
          {{ u.status }} @if (u.error) { — {{ u.error }} }
        </li>
      }
    </ul>
  `,
})
export class MultiUploaderComponent {
  private http = inject(HttpClient);
  uploads = signal<Upload[]>([]);

  addFiles(e: Event) {
    const files = Array.from(($any(e.target) as HTMLInputElement).files ?? []);
    for (const file of files) {
      const upload: Upload = { file, progress: 0, status: 'pending' };
      this.uploads.update(arr => [...arr, upload]);
      this.start(upload);
    }
  }

  private start(upload: Upload) {
    upload.status = 'uploading';
    this.refresh();

    const form = new FormData();
    form.append('file', upload.file);

    this.http.post('/api/upload', form, { reportProgress: true, observe: 'events' })
      .subscribe({
        next: (event: HttpEvent<unknown>) => {
          if (event.type === HttpEventType.UploadProgress && event.total) {
            upload.progress = Math.round(100 * event.loaded / event.total);
          } else if (event.type === HttpEventType.Response) {
            upload.status = 'done';
            upload.progress = 100;
          }
          this.refresh();
        },
        error: (err) => {
          upload.status = 'error';
          upload.error = err.message ?? String(err);
          this.refresh();
        },
      });
  }

  private refresh() { this.uploads.update(arr => [...arr]); }
}

Notice the refresh() helper: because we mutate Upload objects in place, we need to bump the signal's reference so the template re-renders. Better practice would be immutable updates (map a new array each time), but the in-place pattern is shorter and works fine for this size.

Drag and drop #

Add drag-drop support by listening to dragover and drop:

@Component({
  selector: 'app-drop-zone',
  template: `
    <div
      class="drop-zone"
      [class.is-dragging]="dragging()"
      (dragover)="onDragOver($event)"
      (dragleave)="dragging.set(false)"
      (drop)="onDrop($event)"
    >
      Drop files here
    </div>
  `,
  styles: `
    .drop-zone { padding: 40px; border: 2px dashed #ccc; text-align: center; }
    .is-dragging { border-color: dodgerblue; background: #f0f8ff; }
  `,
})
export class DropZoneComponent {
  dragging = signal(false);
  filesDropped = output<File[]>();

  onDragOver(e: DragEvent) {
    e.preventDefault();    // REQUIRED — without this, drop never fires
    this.dragging.set(true);
  }

  onDrop(e: DragEvent) {
    e.preventDefault();
    this.dragging.set(false);
    const files = Array.from(e.dataTransfer?.files ?? []);
    if (files.length) this.filesDropped.emit(files);
  }
}

The e.preventDefault() on dragover is the one detail that catches people — without it, the browser opens the dropped file in a new tab instead of firing your handler.

Compose with the uploader:

<app-drop-zone (filesDropped)="addFiles($event)" />

Validating files before upload #

Client-side checks happen before you call http.post:

addFile(file: File): string | null {
  // Size limit (5 MB)
  if (file.size > 5 * 1024 * 1024) return 'File too large (max 5 MB)';

  // Type check
  if (!['image/jpeg', 'image/png', 'application/pdf'].includes(file.type)) {
    return 'Unsupported type';
  }

  return null;   // valid
}

Always validate server-side too — client-side checks can be bypassed. Client-side is for UX (immediate feedback); server-side is for security.

Cancelling an in-flight upload #

HttpClient returns an Observable; unsubscribing aborts the request:

import { Subscription } from 'rxjs';

upload(file: File) {
  const sub: Subscription = this.http.post('/api/upload', /* ... */).subscribe();
  // ... later:
  sub.unsubscribe();    // aborts the underlying XHR
}

For cleaner cancellation, store the subscription per upload and call .unsubscribe() on a cancel button. With httpResource() (lesson 6.3) cancellation comes for free when the source signal changes.

Common gotchas #

Symptom Cause Fix
Content-Type is application/json and server rejects Manually set Content-Type header Don't — HttpClient handles multipart/form-data automatically
Progress events never fire Missing reportProgress: true or observe: 'events' Both flags are required
drop event never fires Missing e.preventDefault() on dragover Always preventDefault on dragover
File size limit hit at server but not at client No client-side validation Add it for UX; keep server-side for security
Upload completes but UI doesn't update Mutated upload object without triggering signal update Use immutable update OR explicitly trigger via signal.update(arr => [...arr])

What's next #

Lesson 4.6 closes Module 4 with typed reactive forms (FormGroup<T> strict mode) — the migration path for legacy reactive-forms codebases that want better type safety. After that, Module 5 (Routing) starts.

Try it yourself #

A single-file uploader with progress, in 30 lines:

import { Component, signal, inject } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http';

@Component({
  selector: 'app-simple-uploader',
  template: `
    <input type="file" (change)="go($event)" />
    @if (progress() > 0) { <progress [value]="progress()" max="100"></progress> {{ progress() }}% }
  `,
})
export class SimpleUploaderComponent {
  private http = inject(HttpClient);
  progress = signal(0);

  go(e: Event) {
    const file = ($any(e.target) as HTMLInputElement).files?.[0];
    if (!file) return;
    const form = new FormData();
    form.append('file', file);

    this.http.post('/api/upload', form, { reportProgress: true, observe: 'events' })
      .subscribe(event => {
        if (event.type === HttpEventType.UploadProgress && event.total) {
          this.progress.set(Math.round(100 * event.loaded / event.total));
        }
      });
  }
}

Point at any endpoint that accepts multipart and watch progress tick up.

YouShould I chunk large files or send the whole thing in one POST?
YouFor files under ~50 MB, one POST is fine. Beyond that, chunked uploads (slice the file with file.slice(start, end), post each chunk, server reassembles) give you resumability, progress per chunk, and resilience against connection drops. Real production uploaders use libraries like tus-js-client or AWS S3’s multipart upload protocol — don’t roll your own chunking unless you must.

Lesson 4.6 closes Module 4 with typed reactive forms.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *