Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API calls that dispatch multiple actions for keeping API response data and other stuff (like metadata, etc.) in separate parts of the store? (With sample code) #65

Open
anyong opened this issue Apr 15, 2016 · 17 comments

Comments

@anyong
Copy link

anyong commented Apr 15, 2016

I'm working on what is basically a CMS and has about ~30 or so different model classes that are rather inter-related. The easiest way to work with them is definitely with normalizr as suggested. Now, for my redux store object, I'm thinking the best way to set things up will be:

store = {
    // Any loaded entities from the API go here
    entities: {
        // merged directly from from normalizr `entities`
        modelA: {
            1: { // ...
            // ...
        },
        modelB: { // ...
            // ...
    },

    // Keep track of all other normal state for managing UI, pagination, etc...
    modelAListView: {
        page: 1,
        selected: [1, 2, 3, 4, 5] // the ID numbers of the objects currently in view
        // ...
    },
    modelBDetailView: {
        result: 17 // id number of modelB in view
    }
    // etc
}

So, my question is, in order to get this to happen with redux-api-middleware, I need a reducer for the state.entities (the apiReducer), and then my individual reducers as normal for all of the different views and such.

But then, I have to dispatch two separate actions to make sure that (A) the apiReducer receives updates whenever the API gets called, and (B) the appropriate model reducer receives updates when the API call involves that particular model.

I have worked out a solution to do this using redux-thunk, but I would really appreciate any feedback on this approach. So far it's working very nicely, and means my actual API calls are super simple to make from within my redux action creators. I would love to know if there is a better way anyone else has come up with!

So, first, here's my helper utility to make API requests with secondary action effects:

// redux/helpers/makeApiRequest.js

// set a default API root (or headers, credentials, etc.) so we don't need to type these everywhere
const apiRoot = '/api/v1';

export function makeApiRequest (options) {
    const {method = 'GET', path, query, schema, secondaryActionTypes, ...rest} = options;

    const endpoint = url.format({query, pathname: path.join(apiRoot, path)});

    let apiAction;

    // return a function that takes dispatch and add the `redux-thunk` middleware
    return dispatch => {
        if (Array.isArray(secondaryActionTypes) && secondaryActionTypes.length === 3) {

            // These are API hits that require a secondary update in a related reducer
            apiAction = {
                [CALL_API]: {
                    method, endpoint,
                    types: [{
                        type: secondaryActionTypes[0],
                        payload: () => dispatch({type: API_REQUEST}),
                    }, {
                        type: secondaryActionTypes[1],
                        payload: (action, state, res) => onSuccess(dispatch, res, schema),
                                                         // see helper function below
                    }, {
                        type: secondaryActionTypes[2],
                        payload: (action, state, res) => onFailure(dispatch, res),
                                                         // see helper function below
                    }],
                },
            };
        } else {

            // This is a normal `redux-api-middleware` type action for actions
            // that don't require updates specifically to the API entities reducer
            apiAction = {[CALL_API]: {method, endpoint, ...rest}};
        }

        return dispatch(apiAction);
    };
}

function onSuccess (dispatch, res, schema) {
    return getJSON(res)
        .then(json => {
            const data = normalize(json, schema);

            // Dispatch the API Action (will merge with `entities` branch of store)
            dispatch({
                type: API_SUCCESS,
                payload: {
                    entities: data.entities,
                },
            });

            // Payload for the secondary action type, will typically be merged into
            // a related model reducer somewhere else in the store
            return {
                result: data.result,
            };
        });
}

function onFailure (dispatch, res) {
    return getJSON(res)
        .then(json => {
            // Same as the default error action from `redux-api-middleware`
            const payload = new ApiError(res.status, res.statusText, json);

            // Send to the API reducer and return for the secondary reducer
            dispatch({type: API_FAILURE, payload});
            return payload;
        });
}

Now the next one is super simple, to update the entities branch of the store:

// redux/reducers/entities.js

const initialState = {
    modelA: {},
    modelB: {},
    // etc.
};

function reducer (state = initialState, action) {
    switch (action.type) {
        case API_REQUEST:
            // ...
        case API_SUCCESS:
            if (action.payload && action.payload.entities) {
                return Object.assign({}, state, action.payload.entities);
            }
            break;
        case API_FAILURE:
            // ...
        default: return state;
    }
}

export default reducer;

Now calling the API from any action is as easy as:

function getModelA (query) {
    const endpoint = 'model_a';

    return apiRequest({
        endpoint, query,
        schema: Schemas.MODEL_A,
        secondaryActionTypes: [MODEL_A_REQEST, MODEL_A_SUCCESS, MODEL_A_FAILURE],
    });
}

and I will have access to all of the data I need in reducers that handle both API actions and MODEL_A related actions.

Comments/feedback/suggestions? Thanks!

@peterpme
Copy link

Hey this is really great!

I'm just getting my feet wet with this library, but I'm going to apply what you have here to my own project and see if there's anything I would change :)

@peterpme
Copy link

Hey @anyong!

The initialState that you define in your reducer, does that play the same role combineReducers would? Is that where you're going with that?

Are you explicitly listing all of your models there as your root reducer or do you handle that somewhere else too?

Thanks!

@anyong
Copy link
Author

anyong commented Apr 21, 2016

The models are just the normalizr schemas that will be passed to normalize(), I just put them all in a file called models.js. The initialState set up with empty objects for each model simply means you won't get a bunch of cannot access property foo of undefined errors before data is loaded, and any map over the entities will just be an empty array.

@peterpme
Copy link

Thanks for the quick reply!

Gotcha. Just so I understand, it looks like you're getting rid of having to use combineReducers because everything is handled within the api reducer, right?

The reason why I'm asking is because I have a series of reducers that I use with combineReducers:

export default combineReducers({
  router,
  form,
  users,
  vendors,

  // etc..
})

The approach you're taking is a bit different, because the API reducer will handle everything on its own and normalize is actually defining the store at a top level, right? Not nested within a reducer?

Does that make sense?

Thanks

@anyong
Copy link
Author

anyong commented Apr 21, 2016

The top-level entities reducer only takes care of the data. Any other information you need about your state should be coming from another reducer, in particular, things like which models (by ID) are currently displayed on the screen, errors, and that sort of thing. I've got something like:

{
  entities: {
    modelA: { 1: { .... }, 2: { ... } }
  },
  modelAListView: {
    active: [1, 2]
  }
}

Make sense?

@peterpme
Copy link

Hey @anyong

Just wanted to let you know this all makes sense. I took a look through the real-world example and could match the ideas.

Thank you for helping me out!

@peterpme
Copy link

Hey @anyong ,

After playing around with this for a few days. Here are some pain points:

CRUD.

This technique seems to be working great when you only have to fetch stuff. When you try to do much more than that, it gets a little complicated, especially with normalizr.

@peterpme
Copy link

peterpme commented Apr 27, 2016

Ok, sorry for the lack of context. Did a ton of refactoring on my end.

For anyone else interested, this can DEFINITELY be cleaned up, but I just needed something that works for now.

Background: Pure CRUD Api:

DELETE: returns no response body, 204
GET ALL: keyed, ie: customers: [{}]
UPDATE: no key, ie: {_id: xyz}

import {
  CALL_API,
  getJSON,
  ApiError
} from 'redux-api-middleware'
import {normalize} from 'normalizr'
import {omit, values} from 'lodash'

const HEADERS = {
  'Accept': 'application/json',
  'Content-Type': 'application/json'
}

const CREDENTIALS = 'same-origin'

export const API_REQUEST = 'API_REQUEST'
export const API_SUCCESS = 'API_SUCCESS'
export const API_FAILURE = 'API_FAILURE'

const API_ROOT = '/api'

export default function ({
  method = 'GET',
  body,
  headers = HEADERS,
  credentials = CREDENTIALS,
  url,
  actionTypes,
  schema,
  ...rest
}) {
  const endpoint = `${API_ROOT}/${url}`
  let apiAction

  const config = {}
  config.method = method
  config.endpoint = endpoint
  config.headers = headers
  config.credentials = credentials

  if (body) config.body = body

  return dispatch => {
    if (Array.isArray(actionTypes) && actionTypes.length === 3) {
      apiAction = {
        [CALL_API]: {
          ...config,
          types: [
            {
              type: actionTypes[0],
              payload: () => dispatch({type: API_REQUEST})
            },
            {
              type: actionTypes[1],
              payload: (action, state, res) => onSuccess(dispatch, res, schema, method, url, state)
            },
            {
              type: actionTypes[2],
              payload: (action, state, res) => onFailure(dispatch, res, method, url, state)
            }
          ]
        }
      }
    } else {
      apiAction = {
        [CALL_API]: {
          ...config,
          ...rest
        }
      }
    }

    return dispatch(apiAction)
  }
}

function handleNormalResponse (dispatch, res, schema, json) {
  let data
  if (Object.keys(json)[0] === schema.key) {
    data = normalize(json, {[schema.key]: schema.type})
  } else {
    data = normalize(json, schema.type)
  }

  let ids = {}

  if (typeof data.result === 'string') {
    ids = data.result
  } else {
    Object.keys(data.entities).map(entity => {
      ids[entity] = Object.keys(data.entities[entity]).map(id => id)
    })
  }

  dispatch({
    type: API_SUCCESS,
    payload: {
      entities: data.entities,
      ids
    }
  })

  return {
    ids
  }
}

function handleDeleteResponse (dispatch, schema, url, state) {
  const id = url.split('/').pop()
  const ids = state[schema.key].ids

  const items = state.entities[schema.key]
  const toKeep = ids.filter(key => key !== id)
  const itemsToKeep = omit(items, id)

  dispatch({
    type: API_SUCCESS,
    payload: {
      entities: {
        [schema.key]: itemsToKeep
      },
      ids: toKeep
    }
  })

  return {
    ids: toKeep,
    deleted: id
  }
}

function onSuccess (dispatch, res, schema, method, url, state) {
  return getJSON(res)
  .then(json => {
    switch (method) {
      case 'DELETE':
        return handleDeleteResponse(dispatch, schema, url, state)
      default:
        return handleNormalResponse(dispatch, res, schema, json)
    }
  })
}

function onFailure (dispatch, res, state, method) {
  return getJSON(res)
  .then(json => {
    const payload = new ApiError(res.status, res.statusText, json)

    if (payload.status === 401) {
      window.location = '/'
    }

    dispatch({
      type: API_FAILURE,
      payload
    })

    return payload
  })
}

When you fire something off, you'll two things dispatch (thanks to thunks):

API_SUCCESS
WHATEVER_SUCCESS

(and requests/failures go along with it)

This is still going through a bit of a refactor, but:

[API_SUCCESS]: (state, action) => {
    if (action.payload && action.payload.entities) {
    // whether you update, delete or GET, it'll always return the new stuff you want to replace!
   }
}

@janjon
Copy link

janjon commented Jul 5, 2016

@peterpme
Hello, I do not know how you use this module, you give an example?. Thank you

@peterpme
Copy link

peterpme commented Jul 5, 2016

@janjon

  • create api/xhr.js file, paste this in there
  • within your async action creators, call api
  • create an api reducer that handles API_SUCCESS,FAILURE,REQUEST, etc.

:)

@janjon
Copy link

janjon commented Jul 9, 2016

hi @peterpme
How to understand this code?

let data
  if (Object.keys(json)[0] === schema.key) {
    data = normalize(json, {[schema.key]: schema.type})
  } else {
    data = normalize(json, schema.type)
  }

Thank you

@peterpme
Copy link

So this was an edge case I was dealing with. The server would sometimes respond with a keyed payload and sometimes it would return the payload:

customers: {
  name: 'customer 1'
 // ...
}

or

{
name: 'customer 1'
}

Normalizr didn't like that, so I tweaked it a bit.

@janjon
Copy link

janjon commented Jul 11, 2016

@peterpme thank you, Can you help solve this, please。 #91

@janjon
Copy link

janjon commented Jul 25, 2016

hi @peterpme , Sorry, I go again. I have a question that I want to be like real-world as

function fetchStarred(login, nextPageUrl) {
  return {
    login,
    [CALL_API]: {
      types: [ STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE ],
      endpoint: nextPageUrl,
      schema: Schemas.REPO_ARRAY
    }
  }
}

But we would like to achieve this in this example

function fetchStarred(login, nextPageUrl) {
  return  api({
    actionTypes: [
      { type: STARRED_REQUEST, meta: { login: login } },
      { type: STARRED_SUCCESS, meta: { login: login } },
      { type: STARRED_FAILURE, meta: { login: login } }],
  })
}

mapActionToKey: action => action.meta.login,  //To use it like this

There is no better way。

@peterpme
Copy link

you will have to write your own call-api middleware for that unless this middleware supports it. It's relatively easy and http://redux.js.org/ actually has one for you to use :)

@mvhecke
Copy link

mvhecke commented Aug 1, 2016

@peterpme This means that it's also not possible to listen for a promise?

@peterpme
Copy link

peterpme commented Aug 1, 2016

@Gamemaniak whether you write your own custom api middleware or use this one, you can return a promise. I'm not sure what you mean by listening for a promise

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants