Angular File Uploads and Progress: Drag-Drop, Multipart, HttpClient Events [2026]
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:
<input type="file">value is aFileList(one per<input>). Use[0]for single-file mode.FormDatais the standard multipart wrapper.form.append('field', file)becomes aContent-Disposition: form-data; name="field"; filename="X"part.HttpClient.post(url, FormData)auto-setsContent-Type: multipart/form-datawith 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: truetells HttpClient to emit progress eventsobserve: '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.
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
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


