File Upload Progress with Redux-Saga

Redux-Saga

To implement file progress with XMLHttpRequest you need a callback function.

However, this won't work when you want to feed the progress through redux-saga because you can't yield put() the progress value from inside the callback.

I wrote a little about this problem last week -- redux-saga put() from inside a callback -- and mentioned that the solution is to use a redux-saga feature called channels.

Now I'm going to show you what it looks like to put channels in practice.

'Vanilla' JavaScript

First, without redux, reporting on file upload progress looks something like this:

boringUpload.js

// No redux-sagafunction upload(endpoint, file, onProgress, onSuccess, onFailure) {    const xhr = new XMLHttpRequest();    xhr.upload.addEventListener("progress", e => {        if (e.lengthComputable) {            const progress = e.loaded / e.total;            onProgress(progress);        }    });    xhr.onreadystatechange = e => {        // Handle completion and failure    };    xhr.open("POST", endpoint, true);    xhr.send(file);}

Using Redux-Saga Channels

Let's set up some infrastructure before we get to the good stuff.

actions.ts

export const ActionTypes = {    UPLOAD_REQUEST:  'UPLOAD_REQUEST',    UPLOAD_PROGRESS: 'UPLOAD_PROGRESS',    UPLOAD_SUCCESS:  'UPLOAD_SUCCESS',    UPLOAD_FAILURE:  'UPLOAD_FAILURE',};export const uploadRequest = (file: File) => ({    type: ActionTypes.UPLOAD_REQUEST,    payload: file,});export const uploadProgress = (file: File, progress: number) => ({    type: ActionTypes.UPLOAD_PROGRESS,    payload: progress,    meta: { file },});export const uploadSuccess = (file: File) => ({    type: ActionTypes.UPLOAD_SUCCESS,    meta: { file },});export const uploadFailure = (file: File, err: Error) => ({    type: ActionTypes.UPLOAD_FAILURE,    payload: err,    error: true,    meta: { file },});

sagas.ts

import { call, put, take } from 'redux-saga/effects';import { ActionTypes, uploadProgress, uploadSuccess, uploadFailure } from './actions';import { createUploadFileChannel } from './createUploadFileChannel';// Watch for an upload request and then// defer to another saga to perform the actual uploadexport function* uploadRequestWatcherSaga() {    yield takeEvery(ActionTypes.UPLOAD_REQUEST, function*(action) {        const file = action.payload;        yield call(uploadFileSaga, file);    });}// Upload the specified fileexport function* uploadFileSaga(file: File) {    const channel = yield call(createUploadFileChannel, '/some/path', file);    while (true) {        const { progress = 0, err, success } = yield take(channel);        if (err) {            yield put(uploadFailure(file, err));            return;        }        if (success) {            yield put(uploadSuccess(file));            return;        }        yield put(uploadProgress(file, progress));    }}

Take a minute to review uploadFileSaga code above.

We create a channel and then take messages from it in the same way we'd take actions. The take() call blocks the saga until a message is available; and the saga continues until either the upload completes or we encounter an error.

But the real meat of the code is in createFileUploadChannel.

createFileUploadChannel.ts

import { buffers, eventChannel, END } from 'redux-saga';function createUploadFileChannel(endpoint: string, file: File) {    return eventChannel(emitter => {        const xhr = new XMLHttpRequest();        const onProgress = (e: ProgressEvent) => {            if (e.lengthComputable) {                const progress = e.loaded / e.total;                emitter({ progress });            }        };        const onFailure = (e: ProgressEvent) => {            emitter({ err: new Error('Upload failed') });            emitter(END);        };        xhr.upload.addEventListener("progress", onProgress);        xhr.upload.addEventListener("error", onFailure);        xhr.upload.addEventListener("abort", onFailure);        xhr.onreadystatechange = e => {            const { readyState, status } = xhr;            if (readyState === 4) {                if (status === 200) {                    emitter({ success: true });                    emitter(END);                }                else {                    onFailure(null);                }            }        };        xhr.open("POST", endpoint, true);        xhr.send(file);        return () => {            xhr.upload.removeEventListener("progress", onProgress);            xhr.upload.removeEventListener("error", onFailure);            xhr.upload.removeEventListener("abort", onFailure);            xhr.onreadystatechange = null;            xhr.abort();        };    }, buffers.sliding(2));}

First, we create a redux-saga channel. Then we build up our XMLHttpRequest object including callbacks that emit messages to our saga. Note that there's a special END token that we emit to tell redux-saga when we're done with the channel. We also return an unsubscribe function from our emitter that cleans up.

Finally, we tell redux-saga to use a sliding buffer of length two. The reasoning for this is to have enough room to hold at least one progress update along with the END token -- I don't care if old progress updates are lost because we only need the most recent one.

The uploader component might look like the following. Note that I've put a <progress/> element adjacent to an <input type="file"/> element purely for brevity -- once your progress is in redux, separating the progress indicator is trivial.

uploader.tsx

import * as React from 'react';import { connect } from 'react-redux';import { uploadRequest } from './actions';import { getUploadProgress } from './selectors'; // not shown hereexport class UploaderComponent extends React.Component<{}, void> {    upload = e => {        const [ file ] = e.target.files || e.dataTransfer.files;        this.props.onUpload(file);    }    render() {        const { progress } = this.props;        return (            <span>                <input                    type="file"                    onChange={ this.upload }                />                <progress value={ progress }/>            </span>        );    }};const mapStateToProps = (state: any) => ({    progress: getUploadProgress(state),});const mapDispatchToProps = (dispatch: Function) => ({    onUpload: (file: File) => {        dispatch(uploadRequest(file));    },});export const Uploader = connect(mapStateToProps, mapDispatchToProps)(UploaderComponent);

Further Reading


Want More?

I can take the example further...

Sign up below and let me know!

Building asynchronous web applications is complicated. React with Redux is not enough. You need something like Redux-Saga to complete the picture.

I can show you how. Sign up on my email list where I write about Redux-Saga and related web development topics.