DEV Community

loading...
Angular

Angular File Upload with Progress

n_mehlhorn profile image Nils Mehlhorn Originally published at nils-mehlhorn.de ・8 min read

Contents
Angular File Input
Uploading Files with HttpClient
Calculate Upload Progress
Angular Material Progress Bar
Custom RxJS Upload Operator
Conclusion

Since my article on downloading files with Angular was well received, I've decided to also show how to apply the same pattern for uploads.

Uploading files is again a common interaction with web apps. Whether you want your user to upload documents in the PDF format, some archives as ZIP as well as a profile image or some kind of avatar in form of PNG or JPG - you'll need to implement a file upload and chances are that you also want to display some kind of progress indication.

If you're just here for the plain upload and would rather have a simple on/off loading indication, take a look at my post on implementing this with Angular and RxJS after the first two sections.

Here's a live example of the file upload dialog and progress bar which we're going to build. You can also find the code on GitHub.

Tip: You can generate a random big file with OS utilities:

# Ubuntu
shred -n 1 -s 1M big.pdf
# Mac OS X
mkfile -n 1M big.pdf
# Windows
fsutil file createnew big.pdf 1048576
Enter fullscreen mode Exit fullscreen mode

Angular File Input

First, we need to enable the user to select a file to upload. For this, we use a regular <input> element with type="file":

<!-- app.component.html -->
<input type="file" #fileInput (change)="onFileInput(fileInput.files)" />
Enter fullscreen mode Exit fullscreen mode
// app.component.ts
@Component({
  selector: 'ng-upload-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  file: File | null = null

  onFileInput(files: FileList | null): void {
    if (files) {
      this.file = files.item(0)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

It'll render as a button which opens up a file selection dialog. After a file has been selected, the filename will be displayed next to this button. Note that you may additionally specify a list of accepted file types through the accept attribute in form of filename extensions or MIME types. You can also allow the selection of multiple files by setting the multiple attribute to true.

I've bound the input's change event to a component method while passing the input's files attribute that contains a FileList with one or more selected files. I've done this by assigning a template reference variable to the input as it works well with Angular's new strict mode. You might also use the implicit $event variable in the event binding and retrieve the FileList from the change event.

Unfortunately, it's pretty difficult to style file inputs and Angular Material also doesn't provide a corresponding component. Therefore you might want to hide the actual input element and have it triggered by a button next to it. Here's how that could look with Angular Material and the hidden attribute:

<mat-label>File</mat-label>
<button mat-raised-button (click)="fileInput.click()">
  {{ file ? file.name : 'Select' }}
</button>
<input hidden type="file" #fileInput (change)="onFileInput(fileInput.files)" />
Enter fullscreen mode Exit fullscreen mode

Again, I'm using the template reference variable to forward the click for the button to the input element. Since the file is available from the component instance once selected, we can also use its name as the button text.

Uploading Files with HttpClient

Now that we can properly select a file, it's time implement the server upload. Of course, it's a prerequisite that you have a server (implemented with the language or framework of your choice) which can accept a file upload request. That means there's an HTTP POST endpoint for sending a body with the multipart/form-data content-type. For our example I'm using a Node.js server with Express and the express-fileupload middleware. The server code looks like this:

import * as express from 'express'
import * as fileUpload from 'express-fileupload'

const app = express()

app.use(fileUpload())

app.post('/api/upload', (req, res) => {
  console.log(`Successfully uploaded ${req.files.file.name}`)
  res.sendStatus(200)
})

const server = app.listen(3333, () => {
  console.log(`Listening at http://localhost:3333/api`)
})
Enter fullscreen mode Exit fullscreen mode

I'm also configuring a proxy through the Angular CLI so that a request to the Angular development server at http://localhost:4200/api/upload will be proxied to the Node.js backend server at http://localhost:3333/api/upload.

We'll implement the actual HTTP request on the client-side in an Angular service that depends on the HttpClient. There we have a method that accepts a file, encodes it into a FormData body and sends it to the server:

// upload.service.ts
@Injectable({ providedIn: 'root' })
export class UploadService {
  constructor(private http: HttpClient) {}

  upload(file: File): Observable<void> {
    const data = new FormData()
    data.append('file', file)
    return this.http.post('/api/upload', data)
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that the field name 'file' passed to append() is arbitrary. It just needs to correspond with where the server will be looking for the file in the multipart body.

At this point we can add a submit button and method to our component, call the service and trigger the upload by subscribing to the returned observable:

<!-- app.component.html -->
<button
  [disabled]="!file"
  type="submit"
  mat-raised-button
  color="primary"
  (click)="onSubmit()"
>
  Submit
</button>
Enter fullscreen mode Exit fullscreen mode
// app.component.ts
export class AppComponent implements OnDestroy {
  file: File | null = null

  private subscription: Subscription | undefined

  constructor(private uploads: UploadService) {}

  onFileInput(files: FileList | null): void {
    if (files) {
      this.file = files.item(0)
    }
  }

  onSubmit() {
    if (this.file) {
      this.subscription = this.uploads.upload(this.file).subscribe()
    }
  }

  ngOnDestroy() {
    this.subscription?.unsubscribe()
  }
}
Enter fullscreen mode Exit fullscreen mode

Join my mailing list and follow me on Twitter @n_mehlhorn for more in-depth Angular & RxJS knowledge

Calculate Upload Progress

In order to calculate the upload progress we need to pass the reportProgress and observe options for our HTTP request while setting them to true and event respectively. This way, the HttpClient returns and RxJS observable containing an HttpEvent for each step in the upload request. By setting reportProgress to true this will also include events of type HttpProgressEvent which provide information about the number of uploaded bytes as well as the total number of bytes in the file.

// upload.service.ts
import { HttpEvent } from '@angular/common/http'

const data = new FormData()
data.append('file', file)
const upload$: Observable<HttpEvent> = this.http.post('/api/upload', data, {
  reportProgress: true,
  observe: 'events',
})
Enter fullscreen mode Exit fullscreen mode

Then we leverage the RxJS operator scan which can accumulate state from each value emitted by an observable. The resulting observable will always emit the latest calculated state. Our upload state should look as follows:

export interface Upload {
  progress: number
  state: 'PENDING' | 'IN_PROGRESS' | 'DONE'
}
Enter fullscreen mode Exit fullscreen mode

It has a progress property ranging from 0 to 100 and state property that tells us whether the underlying request is pending, currently in progress or done. Our initial state will start out accordingly:

const initialState: Upload = { state: 'PENDING', progress: 0 }
Enter fullscreen mode Exit fullscreen mode

Now we can define how intermediate states are calculated from an existing state and an incoming HttpEvent. But first, I'll setup some user-defined type guards for distinguishing different type of events. These guards are functions which narrow the event type based on the type property that is available in every event:

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

function isHttpResponse<T>(event: HttpEvent<T>): event is HttpResponse<T> {
  return event.type === HttpEventType.Response
}

function isHttpProgressEvent(
  event: HttpEvent<unknown>
): event is HttpProgressEvent {
  return (
    event.type === HttpEventType.DownloadProgress ||
    event.type === HttpEventType.UploadProgress
  )
}
Enter fullscreen mode Exit fullscreen mode

We can then use these guards in if-statements to safely access additional event properties for progress events. Here's the resulting function for calculating the state:

const calculateState = (upload: Upload, event: HttpEvent<unknown>): Upload => {
  if (isHttpProgressEvent(event)) {
    return {
      progress: event.total
        ? Math.round((100 * event.loaded) / event.total)
        : upload.progress,
      state: 'IN_PROGRESS',
    }
  }
  if (isHttpResponse(event)) {
    return {
      progress: 100,
      state: 'DONE',
    }
  }
  return upload
}
Enter fullscreen mode Exit fullscreen mode

If an HttpProgressEvent is emitted, we'll calculate the current progress and set the state property to 'IN_PROGRESS'. We do this by returning a new Upload state from our state calculation function while incorporating information from the incoming event. On the other hand, once the HTTP request is finished, as indicated by an HttpResponse, we can set the progress property to 100 and mark the upload as 'DONE'. For all other events we'll keep (thus return) the state like it is.

Finally, we can pass our initialState and the calculateState function to the RxJS scan operator and apply that to the observable returned from the HttpClient:

// upload.service.ts
@Injectable({ providedIn: 'root' })
export class UploadService {
  constructor(private http: HttpClient) {}

  upload(file: File): Observable<Upload> {
    const data = new FormData()
    data.append('file', file)
    const initialState: Upload = { state: 'PENDING', progress: 0 }
    const calculateState = (
      upload: Upload,
      event: HttpEvent<unknown>
    ): Upload => {
      // implementation
    }
    return this.http
      .post('/api/upload', data)
      .pipe(scan(calculateState, initialState))
  }
}
Enter fullscreen mode Exit fullscreen mode

Eventually, we get an observable that uploads our file while intermediately informing us about the upload state and thus progress.

Angular Material Progress Bar

We can use the Observable<Upload> returned from the service in our component to display a progress bar. Simply assign the upload states to an instance property from inside the subscription callback (or use the AsyncPipe with NgIf):

// app.component.ts
export class AppComponent implements OnDestroy {
  upload: Upload | undefined

  onSubmit() {
    if (this.file) {
      this.subscription = this.uploads
        .upload(this.file)
        .subscribe((upload) => (this.upload = upload))
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Then you can use this state information in the template to show something like the Progress Bar from Angular Material:

<!-- app.component.html -->
<mat-progress-bar
  *ngIf="upload"
  [mode]="upload.state == 'PENDING' ? 'buffer' : 'determinate'"
  [value]="upload.progress"
>
</mat-progress-bar>
Enter fullscreen mode Exit fullscreen mode

Custom RxJS Upload Operator

At this point everything should work just fine. However, if you'd like to re-use the progress logic in several places you could refactor it into a custom RxJS operator like this:

export function upload(): (
  source: Observable<HttpEvent<unknown>>
) => Observable<Upload> {
  const initialState: Upload = { state: 'PENDING', progress: 0 }
  const calculateState = (
    upload: Upload,
    event: HttpEvent<unknown>
  ): Upload => {
    // implementation
  }
  return (source) => source.pipe(scan(reduceState, initialState))
}
Enter fullscreen mode Exit fullscreen mode

The upload operator is also available in the ngx-operators 📚 library - a collection of battle-tested RxJS operators for Angular. I'd appreciate it if you'd give it a star ⭐️ on GitHub, this helps to let people know about it.

You'd use the operator like this:

this.http
  .post('/api/upload', data, {
    reportProgress: true,
    observe: 'events',
  })
  .pipe(upload())
Enter fullscreen mode Exit fullscreen mode

Conclusion

Uploading files is something that's required in many projects. With the presented solution we're able to implement it in a type-safe and re-usable way that works well with the Angular HttpClient and Angular Material. If anything is unclear, don't hesitate to post a comment below or ping me on Twitter @n_mehlhorn.

Discussion (0)

pic
Editor guide