9

I want to dispatch multiple actions from a redux-observable epic. How can I do it? I originally started with

const addCategoryEpic = action$ => {
  return action$.ofType(ADD_CATEGORY_REQUEST)
    .switchMap((action) => {
      const db = firebase.firestore()
      const user = firebase.auth().currentUser
      return db.collection('categories')
        .doc()
        .set({
          name: action.payload.name,
          uid: user.uid
        })
        .then(() => {
          return { type: ADD_CATEGORY_SUCCESS }
        })
        .catch(err => {
          return { type: ADD_CATEGORY_ERROR, payload: err }
        })
    })
}

Now instead of just dispatching ADD_CATEGORY_SUCCESS, I also want to refresh the listing (GET_CATEGORIES_REQUEST). I tried many things but always get

Actions must be plain objects. Use custom middleware for async actions

For example:

const addCategoryEpic = action$ => {
  return action$.ofType(ADD_CATEGORY_REQUEST)
    .switchMap((action) => {
      const db = firebase.firestore()
      const user = firebase.auth().currentUser
      return db.collection('categories')
        .doc()
        .set({
          name: action.payload.name,
          uid: user.uid
        })
        .then(() => {
          return Observable.concat([
            { type: ADD_CATEGORY_SUCCESS },
            { type: GET_CATEGORIES_REQUEST }
          ])
        })
        .catch(err => {
          return { type: ADD_CATEGORY_ERROR, payload: err }
        })
    })
}

Or changing switchMap to mergeMap etc

Jiew Meng
  • 74,635
  • 166
  • 442
  • 756

5 Answers5

15

The issue is that inside your switchMap you're returning a Promise which itself resolves to a concat Observable; Promise<ConcatObservable>. the switchMap operator will listen to the Promise and then emit the ConcatObservable as-is, which will then be provided to store.dispatch under the hood by redux-observable. rootEpic(action$, store).subscribe(store.dispatch). Since dispatching an Observable doesn't make sense, that's why you get the error that actions must be plain objects.

What your epic emits must always be plain old JavaScript action objects i.e. { type: string } (unless you have additional middleware to handle other things)

Since Promises only ever emit a single value, we can't use them to emit two actions, we need to use Observables. So let's first convert our Promise to an Observable that we can work with:

const response = db.collection('categories')
  .doc()
  .set({
    name: action.payload.name,
    uid: user.uid
  })

// wrap the Promise as an Observable
const result = Observable.from(response)

Now we need to map that Observable, which will emit a single value, into multiple actions. The map operator does not do one-to-many, instead we'll want to use one of mergeMap, switchMap, concatMap, or exhaustMap. In this very specific case, which one we choose doesn't matter because the Observable we're applying it to (that wraps the Promise) will only ever emit a single value and then complete(). That said, it's critical to understand the difference between these operators, so definitely take some time to research them.

I'm going to use mergeMap (again, it doesn't matter in this specific case). Since mergeMap expects us to return a "stream" (an Observable, Promise, iterator, or array) I'm going to use Observable.of to create an Observable of the two actions we want to emit.

Observable.from(response)
  .mergeMap(() => Observable.of(
    { type: ADD_CATEGORY_SUCCESS },
    { type: GET_CATEGORIES_REQUEST }
  ))

These two actions will be emitted synchronously and sequentially in the order I provided them.

We need to add back error handling too, so we'll use the catch operator from RxJS--the differences between it and the catch method on a Promise are important, but outside the scope of this question.

Observable.from(response)
  .mergeMap(() => Observable.of(
    { type: ADD_CATEGORY_SUCCESS },
    { type: GET_CATEGORIES_REQUEST }
  ))
  .catch(err => Observable.of(
    { type: ADD_CATEGORY_ERROR, payload: err }
  ))

Put it all together and we'll have something like this:

const addCategoryEpic = action$ => {
  return action$.ofType(ADD_CATEGORY_REQUEST)
    .switchMap((action) => {
      const db = firebase.firestore()
      const user = firebase.auth().currentUser
      const response = db.collection('categories')
        .doc()
        .set({
          name: action.payload.name,
          uid: user.uid
        })

      return Observable.from(response)
        .mergeMap(() => Observable.of(
          { type: ADD_CATEGORY_SUCCESS },
          { type: GET_CATEGORIES_REQUEST }
        ))
        .catch(err => Observable.of(
          { type: ADD_CATEGORY_ERROR, payload: err }
        ))
    })
}

While this works and answers your question, multiple reducers can make state changes from the same single action, which is most often what one should do instead. Emitting two actions sequentially is usually an anti-pattern.

That said, as is common in programming this is not an absolute rule. There definitely are times where it makes more sense to have separate actions, but they are the exception. You're better positioned to know whether this is one of those exceptional cases or not. Just keep it in mind.

jayphelps
  • 14,317
  • 2
  • 38
  • 52
3

Why do you use Observable.concat? SwitchMap waits for Observable or Promise which contains value (array of actions in our case) from its callback function. So no need to return Observable in returned promise success handler. try this one:

const addCategoryEpic = action$ => {
  return action$.ofType(ADD_CATEGORY_REQUEST)
    .switchMap((action) => {
      const db = firebase.firestore()
      const user = firebase.auth().currentUser
      return db.collection('categories')
        .doc()
        .set({
          name: action.payload.name,
          uid: user.uid
        })
        .then(() => {
          return ([
            { type: ADD_CATEGORY_SUCCESS },
            { type: GET_CATEGORIES_REQUEST }
          ])
        })
        .catch(err => {
          return { type: ADD_CATEGORY_ERROR, payload: err }
        })
    })
}
Alexander Poshtaruk
  • 1,824
  • 9
  • 18
  • 1
    I tried this, but it still gives "Actions must be plain objects. Use custom middleware for async actions" – Jiew Meng Dec 25 '17 at 11:38
2

You can also try using store inside your epics

const addCategoryEpic = (action$, store) => {
  return action$.ofType(ADD_CATEGORY_REQUEST)
    .switchMap((action) => {
      const db = firebase.firestore()
      const user = firebase.auth().currentUser
      const query = db.collection('categories')
        .doc()
        .set({
          name: action.payload.name,
          uid: user.uid
        })
       return Observable.fromPromise(query)
    }).map((result) => {
      store.dispatch({ type: ADD_CATEGORY_SUCCESS });
      return ({ type: GET_CATEGORIES_REQUEST })
   })
  .catch(err => {
     return { type: ADD_CATEGORY_ERROR, payload: err }
   })
}
Denis Rybalka
  • 1,471
  • 3
  • 16
  • 23
2

For RxJs6:

action$.pipe(
  concatMap(a => of(Action1, Action2, Action3))
)

Note that we have concatMap, mergeMap and switchMap that could do the job, the differences: https://www.learnrxjs.io/operators/transformation/mergemap.html

keos
  • 797
  • 7
  • 12
1

I found that I should use create an observable using Observable.fromPromise then use flatMap to make multiple actions

const addCategoryEpic = action$ => {
  return action$.ofType(ADD_CATEGORY_REQUEST)
    .switchMap((action) => {
      const db = firebase.firestore()
      const user = firebase.auth().currentUser
      const query = db.collection('categories')
        .doc()
        .set({
          name: action.payload.name,
          uid: user.uid
        })
      return Observable.fromPromise(query)
        .flatMap(() => ([
          { type: ADD_CATEGORY_SUCCESS },
          { type: GET_CATEGORIES_REQUEST }
        ]))
        .catch(err => {
          return { type: ADD_CATEGORY_ERROR, payload: err }
        })
    })
}
Jiew Meng
  • 74,635
  • 166
  • 442
  • 756