[fa icon="calendar"] Publié le 22 May 2017 par Yann Leflour


You probably know how to use methods returning promises in a Redux-Saga but have you ever wondered what to do in case you need to track progress?

Recently I have started a React Native project in which I setup my now usual stack for managing app state, Redux + Redux-Saga. Everything was going well until I installed the React Native Fetch Blob in order to be able to download files in my app.

The fetch method returns what they call a StatefulPromise containing the following:

  • cancel():Promise
  • progress(config, (received, total) => void)
  • then((result) => void)
  • catch((error) => void)

If you are used to the call method, you probably know by now that it can only handle a regular promise. But here we also want to handle progress and to be able to cancel the download.

This is where Saga’s eventChannel comes into play. As you will see, eventChannel will allow you to setup regular functions with multiple callbacks to be used in sagas. The first step is to take the fetch method and wrap it in a Saga helper.

import { eventChannel, END } from 'redux-saga';
import RNFetch from 'react-native-fetch-blob';

export function downloadFileSagaHelper(_id: string, url: string, filePath: string) {
  // An event channel will let you send an infinite number of events
  // It provides you with an emitter to send these events
  // These events can then be picked up by a saga through a "take" method
  return eventChannel(emitter => {
    const task = RNFetch.config({
      path: filePath,
      addAndroidDownloads: {
        useDownloadManager: true,
        title: 'My download',
        notification: true,
      },
    })
      .fetch('GET', url)
      .progress({ interval: 200 }, (received, total) => {
        const progress = received / total * 100;
        // I chose to emit actions immediately     
        emitter({
          type: actionTypes.DOWNLOAD_FILE_PROGRESS,
          payload: { _id, progress },
        });
      })
      .then(res => {
        emitter({
          type: actionTypes.DOWNLOAD_FILE_FINISHED,
          payload: { _id, filePath: res.path() },
        });
        // END event has to be sent to signal that we are done with this channel
        emitter(END);
      })
      .catch(error => {
        // Optional but you can deal with the error here
        throw error;
      });
    
    // The returned method can be called to cancel the channel
    return () => {
      task.cancel();
    };
  });
}

You now have a method usable within a saga:

export function* downloadFileSaga(action) {
  const { _id, url, filePath } = action.payload;
  const channel = yield call(downloadFileSagaHelper, _id, url, filePath);

  try {
    // take(END) will cause the saga to terminate by jumping to the finally block
    while (true) {
      // Remember, our helper only emits actions
    // Thus we can directly "put" them
      const action = yield take(channel);
      yield put(action);
    }
  } catch (error) {
    put({
      type: actionTypes.DOWNLOAD_FILE_ERROR,
      payload: { _id, error },
    });
  } finally {
    // Optional
  }
}

A small drawback is that if the progress event is fired too often it will trigger too many dispatches and renders which in turn will freeze access to your store (and subsenquently your app). This is why we used the { interval: 200 } progress option to limit calls.

If the library you use doesn’t offer this option you can still use a throttler such as lodash’s directly on the callback. Our progress method then ressembles this:

import throttle from 'lodash.throttle';
// ...
progress: throttle(
  (received, total) => {
    const progress = received / total;
    emitter({
      type: actionTypes.DOWNLOAD_FILE_PROGRESS,
      payload: { _id, progress },
    });
  },
  200
),

Hope that helps you setup file progress in your own project!


Liked this article? Interested in building an app with us?

Contact a React Native expert in Paris




Want to rate this article?
Submit Rating

Topics: React, redux