3
votes

So, I have an epic that receives a SUBMIT_LOGIN action and then it should fire the generateDeviceId function that returns an action with an id as payload. After that is processed by the reducer and the store is updated, it should request the login, then resolve it to store and finally redirect the user to our dashboard

const generateDeviceId = (deviceId)  => (({type: GENERATE_DEVICE_ID, payload: deviceId}));

const resolveLogin = (response) => ({type: RESOLVE_LOGIN, payload: response});

const submitLogin = (email, password) => ({type: SUBMIT_LOGIN, payload: {email, password}});

const requestLogin = (email, password) => ({type: REQUEST_LOGIN, payload: {email, password}});

const loadAbout = () => ({type: LOAD_ABOUT});

const submitLoginEpic = (action$) =>
        action$
            .ofType(SUBMIT_LOGIN)
            .mapTo(generateDeviceId(uuidv1()))
                .flatMap(({payload}) => login(payload.email, payload.password)
                    .flatMap(({response}) => [resolveLogin(response.content), loadAbout()])
                );

ps: login function is an ajax from rx-dom that returns a stream:

const AjaxRequest = (method, url, data) => {

    const state = store.getState();
    const {token, deviceId} = state.user;

    return ajax({
        method,
        timeout: 10000,
        body: data,
        responseType: 'json',
        url: url,
        headers: {
            token,
            'device-id': deviceId,
            'Content-Type': 'application/json'
        }
    });

};



 const login = (email, password) => AjaxRequest('post', 'sign_in', {email, password});

ps2: uuidv1 function just generates a random key (its a lib)

I think (actually I'm sure) that I'm doing it wrong, but after two days i don't really know how to proceed. :/

UPDATE

After Sergey's first update I've changed my epic to that, but unfortunately for some reason rx-dom's ajax is not working like Sergey's login$ observable. We're currently working on this.

const generateDeviceId = (deviceId)  => (({type: GENERATE_DEVICE_ID, payload: deviceId}));

const resolveLogin = (response) => ({type: RESOLVE_LOGIN, payload: response});

const submitLogin = (email, password) => ({type: SUBMIT_LOGIN, payload: {email, password}});

const requestLogin = (email, password) => ({type: REQUEST_LOGIN, payload: {email, password}});

const loadAbout = () => ({type: LOAD_ABOUT});

const submitLoginEpic = action$ =>
  action$.ofType(SUBMIT_LOGIN)
    .mergeMap(({payload}) =>
      Observable.of(generateDeviceId(uuid()))
        .concat(login(payload.email, payload.password)
          .concatMap(({response}) => [resolveLogin(response.content), loadAbout()])

UPDATE 2

After Sergey's second update I've changed my code again and ended up with a solution where I use two epics and .concatMap operator in order to synchronously dispatch the actions and it works as expected.

const generateDeviceId = (deviceId)  => (({type: GENERATE_DEVICE_ID, payload: deviceId}));

const resolveLogin = (response) => ({type: RESOLVE_LOGIN, payload: response});

const submitLogin = (email, password) => ({type: SUBMIT_LOGIN, payload: {email, password}});

const requestLogin = (email, password) => ({type: REQUEST_LOGIN, payload: {email, password}});

const loadAbout = () => ({type: LOAD_ABOUT});

const submitLoginEpic = (action$) =>
  action$
    .ofType(SUBMIT_LOGIN)
    .concatMap(({payload}) => [
      generateDeviceId(uuid()),
      requestLogin(payload.email, payload.password)
    ]);

const requestLoginEpic = (action$) =>
  action$
    .ofType(REQUEST_LOGIN)
    .mergeMap(({payload}) => login(payload.email, payload.password)
      .concatMap(({response}) => [resolveLogin(response.content), loadAbout()])
1

1 Answers

4
votes

If I got it right, you want your epic to produce the following sequence of actions in response to each SUBMIT_LOGIN:

GENERATE_DEVICE_ID -- RESOLVE_LOGIN -- LOAD_ABOUT

Also, I guess that GENERATE_DEVICE_ID needs to be issued immediately after SUBMIT_LOGIN is received, while RESOLVE_LOGIN and LOAD_ABOUT should be issued only after a stream returned by login() emits.

If my guess is correct, then you just need to start the nested observable (the one created per each SUBMIT_LOGIN) with GENERATE_DEVICE_ID action and startWith operator does exactly that:

const submitLoginEpic = action$ =>
    action$.ofType(SUBMIT_LOGIN)
        .mergeMap(({ payload }) =>
            login(payload.email, payload.password)
                .mergeMap(({ response }) => Rx.Observable.of(resolveLogin(response.content), loadAbout()))
                .startWith(generateDeviceId(uuidv1()))
        );

Update: one possible alternative could be to use concat operator: obs1.concat(obs2) subscribes to the obs2 only when obs1 has completed.

Note also that if login() needs to be called after GENERATE_DEVICE_ID has been dispatched, you might want to wrap it in a "cold" observable:

const login$ = payload =>
    Rx.Observable.create(observer => {
        return login(payload.email, payload.password).subscribe(observer);
    });

const submitLoginEpic = action$ =>
    action$.ofType(SUBMIT_LOGIN)
        .mergeMap(({ payload }) =>
            Rx.Observable.of(generateDeviceId(uuidv1()))
                .concat(login$(payload).map(({ response }) => resolveLogin(response.content)))
                .concat(Rx.Observable.of(loadAbout()))
        );

This way GENERATE_DEVICE_ID is emitted before login() is called, i.e. the sequence would be

GENERATE_DEVICE_ID -- login() -- RESOLVE_LOGIN -- LOAD_ABOUT

Update 2: The reason why login() works not as expected is because it depends on an external state (const state = getCurrentState()) which is different at the points in time when login() is called and when an observable returned by login() is subscribed to. AjaxRequest captures the state at the point when login() is called, which happens before GENERATE_DEVICE_ID is dispatched to the store. At that point no network request is performed yet, but ajax observable is already configured based on a wrong state.

To see what happens, let's simplify the things a bit and rewrite the epic this way:

const createInnerObservable = submitLoginAction => {
    return Observable.of(generateDeviceId()).concat(login());
}

const submitLoginEpic = action$ =>
    action$.ofType(SUBMIT_LOGIN).mergeMap(createInnerObservable);

When SUBMIT_LOGIN action arrives, mergeMap() first calls createInnerObservable() function. The function needs to create a new observable and to do that it has to call generateDeviceId() and login() functions. When login() is called, the state is still old as at this point the inner observable has not been created and thus there was no chance for GENERATE_DEVICE_ID to be dispatched. Because of that login() returns an ajax observable configured with an old data and it becomes a part of the resulting inner observable. As soon as createInnerObservable() returns, mergeMap() subscribes to the returned inner observable and it starts to emit values. GENERATE_DEVICE_ID comes first, gets dispatched to the store and the state gets changed. After that, ajax observable (which is now a part of the inner observable) is subscribed to and performs a network request. But the new state has no effect on that as ajax observable has already been initialized with an old data.

Wrapping login into an Observable.create postpones the call until an observable returned by Observable.create is subscribed to, and at that point the state is already up-to-date.

An alternative to that could be introducing an extra epic which would react to GENERATE_DEVICE_ID action (or a different one, whichever suits your domain) and send a login request, e.g.:

const submitLogin = payload => ({ type: "SUBMIT_LOGIN", payload });

// SUBMIT_LOGIN_REQUESTED is what used to be called SUBMIT_LOGIN
const submitLoginRequestedEpic = action$ =>
    action$.ofType(SUBMIT_LOGIN_REQUESTED)
        .mergeMap(({ payload }) => Rx.Observable.of(
            generateDeviceId(uuidv1()),
            submitLogin(payload))
        );

const submitLoginEpic = (action$, store) =>
    action$.ofType(SUBMIT_LOGIN)
        .mergeMap(({ payload }) => {
            // explicitly pass all the data required to login
            const { token, deviceId } = store.getState().user;
            return login(payload.email, payload.password, token, deviceId)
                .map(({ response }) => resolveLogin(response.content))
                .concat(loadAbout());
        });

Learning Resources

As redux-observable is based on RxJS, it makes sense to get comfortable with Rx first.

I highly recommend watching "You will learn RxJS" talk by André Staltz. It should give an intuition of what observables are and how they work under the hood.

André has also authored these remarkable lessons on egghead:

Also Jay Phelps has given a brilliant talk on redux-observable, it definitely worth watching.