reactjsreduxreact-reduxpromiseredux-saga

Is there Promise.any like thing in redux saga?


I know about Redux Saga's all([...effects]) effect combinator that is very similar to Promise.all utility, but I've not found something similar to Promise.any behavior that will:

e.g.

export function* getHomeDataSaga() {
  yield* any([
    call(getTopUsersSaga, { payload: undefined }),
    call(getFavoritesSaga, { payload: undefined }),
    call(getTrendingTokensSaga, { payload: undefined }),
    call(getTopCollectionsSaga, { payload: { itemsPerPage: 9, page: 1 } }),
  ]);
}

This would be very useful when you want to group multiple (decomposed) sagas in to a single saga, it won't fail-fast but finish all effects.

Answer

Based on Martin Kadlec answer ended up using:


export function* anyCombinator(effects: SagaGenerator<any, any>[]) {
  const errors = yield* all(
    effects.map((effect) =>
      call(function* () {
        try {
          yield* effect;
          return null;
        } catch (error) {
          return error;
        }
      }),
    ),
  );

  if (errors.every((error) => error !== null)) {
    throw new AggregateError(errors);
  }
}

Solution

  • There isn't an existing effect that would do that, but you can create your own utility that will do that for you. The any functionality is very similar to the all functionality in that in one case you will get all the results/errors and in the other you get the first one that succeeds/fails. So you can easily get the any functionality by flipping the all effect -> for each item you throw on success and return on error.

    const sagaAny = (effects = []) => {
      const taskRunner = function* (effect) {
        let value;
        try {
          value = yield effect;
        } catch (err) {
          // On error, we want to just return it 
          // to map it later to AggregateError
          return err;
        }
        // On success we want to cancel all the runners
        // we do that by throwing here
        throw value;
      };
    
      return call(function* () {
        try {
          const runners = effects.map((effect) => call(taskRunner, effect));
          // If one of the runners throws on success the all effect will
          // cancel all the other runners
          const failedResults = yield all(runners);
          throw new AggregateError(failedResults, "SAGA_ANY");
        } catch (err) {
          if (err instanceof AggregateError) throw err;
          return err;
        }
      });
    };
    
    function* getHomeDataSaga() {
      const result = yield sagaAny([
        call(getTopUsersSaga, { payload: undefined }),
        call(getFavoritesSaga, { payload: undefined }),
        call(getTrendingTokensSaga, { payload: undefined }),
        call(getTopCollectionsSaga, { payload: { itemsPerPage: 9, page: 1 } }),
      ]);
    }
    

    In case you would prefer not to cancel the other sagas once one succeeds, things get a bit trickier because in standard fork tree the main saga (e.g. getHomeDataSaga) would wait until all the forked sagas (task runners) are done before continuing. To get around that we can use the spawn effect, which will not block the main saga though it has some other implications (e.g. if you kill them main saga the spawned sagas will continue running).

    Something like this should do the trick:

    const sagaAny = (effects = []) => {
      const taskRunner = function* (effect, resultsChannel) {
        try {
          value = yield effect;
          yield put(resultsChannel, { type: "success", value });
        } catch (err) {
          yield put(resultsChannel, { type: "error", value: err });
        }
      };
    
      return call(function* () {
        const resultsChannel = yield call(channel);
        yield all(
          effects.map((effect) => spawn(taskRunner, effect, resultsChannel))
        );
        const errors = [];
        while (errors.length < effects.length) {
          const result = yield take(resultsChannel);
          if (result.type === "success") {
            yield put(resultsChannel, END);
            return result.value;
          }
          if (result.type === "error") errors.push(result.value);
        }
        throw new AggregateError(errors, "SAGA_ANY");
      });
    };
    

    I use custom channel here to send the results from the spawned runners to the utility saga so that I can react to each finished runner based on my needs.