mikechabot/redux-entity

How do I implement follow-ups after a redux-entity thunk promise?

andrewsantarin opened this issue · 8 comments

Hi, @mikechabot!

I've been trying out react-boilerplate and I've been wondering exactly how I should script, let's say, actions that should be taken after the thunk promise has been completed.

Let's say these segments represent my authentication model:

// auth-actions.js
import { loadEntity } from 'redux-entity';
import AuthService from './services/authentication-service';

/**
 * Login function, which saves the user
 *
 * @param user (user.username, user.password)
 * @returns {Promise}
 */
export function authenticate(user) {
    return loadEntity(
        'auth',
        AuthenticationService.authenticate(user)
        // .then(data => { }).catch(error => { })
        // ... will still complete the service but won't `then-catch` and won't bind the model!
    );
}
// authentication-service.js
import DataAccessService from '../data/data-access-service';

const AuthenticationService = {
    authenticate(user) {
        return DataAccessService.post('/auth/login/', {
            user
        });
    }
};

export default AuthenticationService;

I understand that loadEntity() will bind AuthService.authenticate() with the model state, which works for me, but I don't see an example of, let's say, storing the token in localStorage or cookie, after it's retrieved from the service.

Normally, for this, I would go "old school" with just one action creator:

import axios from 'axios';
import jwtDecode from 'jwt-decode';
import cookie from 'js-cookie';
const config = require('../../../config/config');

export function authenticate(user) {
    return dispatch => {
        dispatch(authenticateRequest());
        return axios({
            method: 'POST',
            baseUrl: baseUrl,
            url: '/auth/login/',
            data: user
        })
        .then(checkHttpStatus) // returns the response only if HTTP: 200 >= HTTP > 300
        .then(getResponseData) // returns something like const { data } = response
        .then(response =>{
            const { token, expires } = response.data;
            const lifetime = expires ? { expires: result.expires / (60 * 60 * 24) } : undefined;
            cookie('token', token, lifetime);
            localStorage('token', token);
            dispatch(authenticateSuccess(jwtDecode(token)));
        }).catch(error => {
            dispatch(authenticateFailure(error));
        });
    }
}

But it looks a lot more convenient if I could use your model! How does your approach handle this?

@andrewsantarin I see your issue. There are definitely some cases where one would need to perform some post-processing on the response data before immediately providing it back to the caller.

Here's loadEntity as it currently exists (comments removed):

function loadEntity(
    name,
    promise,
    silent
) {
    if (!name || typeof name !== 'string') throw new Error('name is required and must be a String');
    if (!promise || !promise.then) throw new Error('promise is required and must be a Promise');

    return (dispatch) => {

        if (!silent) {
            dispatch(actionCreators.fetchRequest(name)());
        }

        return promise
            .then(data => {
                dispatch(
                    actionCreators.fetchSuccess(name)(data, Date.now())
                )
            })
            .catch(error => {
                dispatch(
                    actionCreators.fetchFailure(name)(error, Date.now())
                )
            })
    }
};

However, it might be worth adding fourth argument, maybe postprocessor, which, if supplied, can process the result set before dispatching the success event:

loadEntity(
    name,
    promise,
    silent,
    postprocessor
) {
    if (!name || typeof name !== 'string') throw new Error('name is required and must be a String');
    if (!promise || !promise.then) throw new Error('promise is required and must be a Promise');
    if (postprocessor && typeof postprocessor !== 'function') throw new Error('postprocessor must be a function');

    return (dispatch) => {

        if (!silent) {
            dispatch(actionCreators.fetchRequest(name)());
        }

        return promise
            .then(data => {
                if (postprocessor) {
                    data = postprocessor(data);
                }
                dispatch(
                    actionCreators.fetchSuccess(name)(data, Date.now())
                )
            })
            .catch(error => {

                dispatch(
                    actionCreators.fetchFailure(name)(error, Date.now())
                )
            })
    }
};

To implement this, you new loadEntity might look like:

export function authenticate(user) {
    return loadEntity(
        'auth',
        AuthenticationService.authenticate(user),
        false,
        AuthenticationService.postprocess
    );
}

And the postprocess function could live in your AuthService:

const AuthenticationService = {
    authenticate(user) {
        return DataAccessService.post('/auth/login/', {
            user
        });
    },
    postprocess(response): {
        const { token, expires } = response.data;
        const lifetime = expires ? { expires: result.expires / (60 * 60 * 24) } : undefined;
        cookie('token', token, lifetime);
        localStorage('token', token);
        return response;
    }
};

I haven't given this scenario too much thought, but this optional postprocessor could suffice. Would this meet your need, or would it be too clunky to have to create this additional function in order to manipulate the response data? I'm open to suggestions on what might work easier.

Mike

@mikechabot I'd wager that your suggestion may work in my case†, but I can't say for sure until I try it. Is there a way I can test the suggestion?

† I don't foresee any cases where I need to run something after dispatch( actionCreators.fetchSuccess(name)(data, Date.now()) ). Hope I'll never need to.

P.S. sorry, Mike. clicked "close and comment" by mistake.

I cut a new build with processor logic included - and it's accessible via:

npm install --save redux-entity@2.1.0-alpha.2

And loadEntity now accepts four arguments, where the third argument is an object called processors. This object is completely optional, and can be omitted

export function loadEntity(name, promise, processors, silent) {
  ....
}

This processors object can have four properties, which get fired off at various stages in the loading process.

Every processor is always passed the dispatch function from redux as the first argument (be careful!). And the second argument is either data or error depending on whether the promise resolved or rejected:

const myProcessor = {
    beforeSuccess: ((dispatch, data) => {
       console.log('Processing before SUCCCESS is dispatched!');
    },
    afterSuccess: ((dispatch, data) => {
       console.log('Processing after SUCCCESS is dispatched!');
    },
    beforeFailure: ((dispatch, error) => {
       console.log('Processing before FAILURE is dispatched!');
    },
    afterFailure: ((dispatch, error) => {
       console.log('Processing after FAILURE is dispatched!');
    },
}

In your world, this might look like:

export function authenticate(user) {
    return loadEntity(
        'auth',
        AuthenticationService.authenticate(user),
        {
            beforeSuccess: (dispatch, data) {
                const { token, expires } = data;
                const lifetime = expires ? { expires: result.expires / (60 * 60 * 24) } : undefined;
                cookie('token', token, lifetime);
                localStorage('token', token);
                dispatch(authenticateSuccess(jwtDecode(token)));
            }
        }
    );
}

Let me know if you hit any snags, or have any trouble implementing the new processors object. You can see here that we just fire off the functions (if available in the processor):

https://github.com/mikechabot/redux-entity/blob/master/src/thunk.js#L51

Oh, and aside from this new processor logic, which I think serves a more generic purpose - you could resolve your issue most likely by returning a new Promise - perform your work with localStorage then resolve with the data that will be bound to state.model.

Simplest of solutions if the processor stuff is overkill for this issue:

export function authenticate(user) {
    return loadEntity(
        'auth',
        new Promise((resolve, reject) => {
              AuthenticationService.authenticate(user)
                  .then(data => {
                         const { token, expires } = data;
                         const lifetime = expires ? { expires: result.expires / (60 * 60 * 24) } : undefined;
                         cookie('token', token, lifetime);
                         localStorage('token', token);
                         resolve(data);
                  })
                 .catch(error => {
                     reject(error);
                 })
        ))
    );
}

Yes, yes! Thank you, @mikechabot!

I'm actually quite hesitant to use a processor-tweaked redux-entity to solve this problem, because I know there's a custom Promise approach to this but I couldn't quite get it down. I like your second solution, so I can do this:

/* authentication-actions.js */
export function authenticate(user) {
    return loadEntity(
        'auth',
        new Promise((resolve, reject) => {
            AuthenticationService.authenticate(user)
            .then(data => {
                return AuthenticationService.credentialize(data);
            }).then(data => {
                resolve(data);
            }).catch(error => {
                reject(error);
            });
        })
    );
}
/* authentication-service.js */
const AuthenticationService = {
    authenticate(user) {
        return DataAccessService.postApi('/auth/login/', user);
    },

    credentialize(data) {
        if (!isEmpty(data.token)) {
            const { token, duration } = data.token;
            const user = jwtDecode(token);
            const expires = duration ? { expires: duration / (60 * 60 * 24) } : undefined;

            cookie.set('token', token, expires);
            cookie.set('user', user);

            const auth = {
                ...user,
                token: token
            };

            return auth;
        } else {
            throw {
                reason: data.msg
            };
        }
    }
};

export default AuthenticationService;

Still, I'd like to test the alpha build on a separate branch on my project and provide feedback for it. The processor parameter could come in very handy. Let's say I need to know the axios response status and statusText before redux-entity extracts data and completely disposes everything else.

Again, thanks, Mike!

Awesome! Yeah, the wrapped Promise is the most straightforward approach, but I'm glad you generally brought this issue to my attention, so I could add the generic processsor logic as it would be helpful regardless.

I'll close this out, but feel free to open a new issue if you have any feedback on the alpha cut. I implemented it rather haphazardly so things may obviously change.

Sure thing, @mikechabot! I agree to close this issue. I'll be sure to write a new one for redux-entity@2.1.0-alpha.2 in case I find any issues with the processor logic.