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 = () => { 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
- redux-saga eventChannel
- redux-saga buffers
- XMLHttpRequest.upload
- Redux Hero Part 4: An Introduction to Redux-Saga
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!