File Upload Progress with 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

function 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 => { }; 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' ; export function* uploadRequestWatcherSaga () { yield takeEvery ( ActionTypes . UPLOAD _ REQUEST , function* ( action ) { const file = action . payload ; yield call ( uploadFileSaga , file ); }); } export 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 = () => { 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' ; export 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...

e.g. improve UI with custom graphics / animations

e.g. coordinating animations from the saga

e.g. show how to upload directly to Amazon S3

e.g. other requests?

Sign up below and let me know!